1use std::path::Path;
7
8use crate::Result;
9use crate::SecurityConfig;
10use crate::security::hardlink::HardlinkTracker;
11use crate::security::path::validate_path;
12use crate::security::permissions::sanitize_permissions;
13use crate::security::quota::QuotaTracker;
14use crate::security::symlink::validate_symlink;
15use crate::security::zipbomb::validate_compression_ratio;
16use crate::types::DestDir;
17use crate::types::EntryType;
18use crate::types::SafePath;
19use crate::types::SafeSymlink;
20
21#[derive(Debug)]
25pub struct ValidatedEntry {
26 pub safe_path: SafePath,
28
29 pub entry_type: ValidatedEntryType,
31
32 pub mode: Option<u32>,
34}
35
36#[derive(Debug)]
38pub enum ValidatedEntryType {
39 File,
41
42 Directory,
44
45 Symlink(SafeSymlink),
47
48 Hardlink {
50 target: SafePath,
52 },
53}
54
55pub struct EntryValidator<'a> {
101 config: &'a SecurityConfig,
102 dest: &'a DestDir,
103 quota_tracker: QuotaTracker,
104 hardlink_tracker: HardlinkTracker,
105}
106
107impl<'a> EntryValidator<'a> {
108 #[must_use]
110 pub fn new(config: &'a SecurityConfig, dest: &'a DestDir) -> Self {
111 Self {
112 config,
113 dest,
114 quota_tracker: QuotaTracker::new(),
115 hardlink_tracker: HardlinkTracker::new(),
116 }
117 }
118
119 pub fn validate_entry(
171 &mut self,
172 path: &Path,
173 entry_type: &EntryType,
174 uncompressed_size: u64,
175 compressed_size: Option<u64>,
176 mode: Option<u32>,
177 ) -> Result<ValidatedEntry> {
178 let safe_path = validate_path(path, self.dest, self.config)?;
179
180 if matches!(entry_type, EntryType::File) {
181 self.quota_tracker
182 .record_file(uncompressed_size, self.config)?;
183 }
184
185 if let Some(compressed) = compressed_size {
186 validate_compression_ratio(compressed, uncompressed_size, self.config)?;
187 }
188
189 let (validated_type, sanitized_mode) = match entry_type {
190 EntryType::File => {
191 let sanitized = if let Some(m) = mode {
192 Some(sanitize_permissions(safe_path.as_path(), m, self.config)?)
193 } else {
194 None
195 };
196 (ValidatedEntryType::File, sanitized)
197 }
198
199 EntryType::Directory => (ValidatedEntryType::Directory, None),
200
201 EntryType::Symlink { target } => {
202 let safe_symlink = validate_symlink(&safe_path, target, self.dest, self.config)?;
203 (ValidatedEntryType::Symlink(safe_symlink), None)
204 }
205
206 EntryType::Hardlink { target } => {
207 self.hardlink_tracker.validate_hardlink(
209 &safe_path,
210 target,
211 self.dest,
212 self.config,
213 )?;
214
215 let target_safe = SafePath::new_unchecked(target.clone());
218
219 (
220 ValidatedEntryType::Hardlink {
221 target: target_safe,
222 },
223 None,
224 )
225 }
226 };
227
228 Ok(ValidatedEntry {
229 safe_path,
230 entry_type: validated_type,
231 mode: sanitized_mode,
232 })
233 }
234
235 #[must_use]
240 pub fn finish(self) -> ValidationReport {
241 ValidationReport {
242 files_validated: self.quota_tracker.files_extracted(),
243 total_bytes: self.quota_tracker.bytes_written(),
244 hardlinks_tracked: self.hardlink_tracker.count(),
245 }
246 }
247}
248
249#[derive(Debug)]
251pub struct ValidationReport {
252 pub files_validated: usize,
254
255 pub total_bytes: u64,
257
258 pub hardlinks_tracked: usize,
260}
261
262#[cfg(test)]
263#[allow(
264 clippy::unwrap_used,
265 clippy::expect_used,
266 clippy::field_reassign_with_default
267)]
268mod tests {
269 use super::*;
270 use std::path::PathBuf;
271 use tempfile::TempDir;
272
273 #[test]
274 fn test_entry_validator_new() {
275 let temp = TempDir::new().expect("failed to create temp dir");
276 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
277 let config = SecurityConfig::default();
278 let validator = EntryValidator::new(&config, &dest);
279 let report = validator.finish();
280 assert_eq!(report.files_validated, 0);
281 assert_eq!(report.total_bytes, 0);
282 assert_eq!(report.hardlinks_tracked, 0);
283 }
284
285 #[test]
286 fn test_validate_file_entry() {
287 let temp = TempDir::new().expect("failed to create temp dir");
288 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
289 let config = SecurityConfig::default();
290 let mut validator = EntryValidator::new(&config, &dest);
291
292 let result = validator.validate_entry(
293 Path::new("file.txt"),
294 &EntryType::File,
295 1024,
296 None,
297 Some(0o644),
298 );
299
300 assert!(result.is_ok());
301 let entry = result.unwrap();
302 assert_eq!(entry.safe_path.as_path(), Path::new("file.txt"));
303 assert!(matches!(entry.entry_type, ValidatedEntryType::File));
304 assert_eq!(entry.mode, Some(0o644));
305 }
306
307 #[test]
308 fn test_validate_directory_entry() {
309 let temp = TempDir::new().expect("failed to create temp dir");
310 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
311 let config = SecurityConfig::default();
312 let mut validator = EntryValidator::new(&config, &dest);
313
314 let result =
315 validator.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None);
316
317 assert!(result.is_ok());
318 let entry = result.unwrap();
319 assert!(matches!(entry.entry_type, ValidatedEntryType::Directory));
320 assert!(entry.mode.is_none());
321 }
322
323 #[test]
324 fn test_validate_path_traversal_rejected() {
325 let temp = TempDir::new().expect("failed to create temp dir");
326 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
327 let config = SecurityConfig::default();
328 let mut validator = EntryValidator::new(&config, &dest);
329
330 let result = validator.validate_entry(
331 Path::new("../etc/passwd"),
332 &EntryType::File,
333 1024,
334 None,
335 Some(0o644),
336 );
337
338 assert!(result.is_err());
339 }
340
341 #[test]
342 fn test_quota_exceeded_file_size() {
343 let temp = TempDir::new().unwrap();
344 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
345 let mut config = SecurityConfig::default();
346 config.max_file_size = 100;
347 let mut validator = EntryValidator::new(&config, &dest);
348
349 let result = validator.validate_entry(
350 Path::new("large.txt"),
351 &EntryType::File,
352 1000,
353 None,
354 Some(0o644),
355 );
356
357 assert!(result.is_err());
358 }
359
360 #[test]
361 fn test_quota_exceeded_file_count() {
362 let temp = TempDir::new().unwrap();
363 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
364 let mut config = SecurityConfig::default();
365 config.max_file_count = 2;
366 let mut validator = EntryValidator::new(&config, &dest);
367
368 assert!(
369 validator
370 .validate_entry(
371 Path::new("file1.txt"),
372 &EntryType::File,
373 100,
374 None,
375 Some(0o644)
376 )
377 .is_ok()
378 );
379 assert!(
380 validator
381 .validate_entry(
382 Path::new("file2.txt"),
383 &EntryType::File,
384 100,
385 None,
386 Some(0o644)
387 )
388 .is_ok()
389 );
390
391 let result = validator.validate_entry(
392 Path::new("file3.txt"),
393 &EntryType::File,
394 100,
395 None,
396 Some(0o644),
397 );
398 assert!(result.is_err());
399 }
400
401 #[test]
402 fn test_zip_bomb_detected() {
403 let temp = TempDir::new().expect("failed to create temp dir");
404 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
405 let config = SecurityConfig::default();
406 let mut validator = EntryValidator::new(&config, &dest);
407
408 let result = validator.validate_entry(
409 Path::new("bomb.txt"),
410 &EntryType::File,
411 1_000_000,
412 Some(100),
413 Some(0o644),
414 );
415
416 assert!(result.is_err());
417 }
418
419 #[test]
420 fn test_validation_report() {
421 let temp = TempDir::new().expect("failed to create temp dir");
422 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
423 let config = SecurityConfig::default();
424 let mut validator = EntryValidator::new(&config, &dest);
425
426 validator
427 .validate_entry(
428 Path::new("file1.txt"),
429 &EntryType::File,
430 1024,
431 None,
432 Some(0o644),
433 )
434 .unwrap();
435
436 validator
437 .validate_entry(
438 Path::new("file2.txt"),
439 &EntryType::File,
440 2048,
441 None,
442 Some(0o644),
443 )
444 .unwrap();
445
446 let report = validator.finish();
447 assert_eq!(report.files_validated, 2);
448 assert_eq!(report.total_bytes, 1024 + 2048);
449 }
450
451 #[test]
452 fn test_sanitize_permissions_setuid() {
453 let temp = TempDir::new().expect("failed to create temp dir");
454 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
455 let config = SecurityConfig::default();
456 let mut validator = EntryValidator::new(&config, &dest);
457
458 let result = validator.validate_entry(
459 Path::new("file.txt"),
460 &EntryType::File,
461 1024,
462 None,
463 Some(0o4755),
464 );
465
466 assert!(result.is_ok());
467 let entry = result.unwrap();
468 assert_eq!(entry.mode, Some(0o755)); }
470
471 #[test]
472 fn test_symlink_validation() {
473 let temp = TempDir::new().unwrap();
474 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
475 let mut config = SecurityConfig::default();
476 config.allowed.symlinks = true;
477 let mut validator = EntryValidator::new(&config, &dest);
478
479 let result = validator.validate_entry(
480 Path::new("link"),
481 &EntryType::Symlink {
482 target: PathBuf::from("target.txt"),
483 },
484 0,
485 None,
486 None,
487 );
488
489 assert!(result.is_ok());
490 let entry = result.unwrap();
491 assert!(matches!(entry.entry_type, ValidatedEntryType::Symlink(_)));
492 }
493
494 #[test]
495 fn test_hardlink_validation() {
496 let temp = TempDir::new().unwrap();
497 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
498 let mut config = SecurityConfig::default();
499 config.allowed.hardlinks = true;
500 let mut validator = EntryValidator::new(&config, &dest);
501
502 let result = validator.validate_entry(
503 Path::new("link"),
504 &EntryType::Hardlink {
505 target: PathBuf::from("target.txt"),
506 },
507 0,
508 None,
509 None,
510 );
511
512 assert!(result.is_ok());
513 let entry = result.unwrap();
514 assert!(matches!(
515 entry.entry_type,
516 ValidatedEntryType::Hardlink { .. }
517 ));
518 }
519
520 #[test]
521 fn test_multiple_entries_with_report() {
522 let temp = TempDir::new().unwrap();
523 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
524 let mut config = SecurityConfig::default();
525 config.allowed.hardlinks = true;
526 let mut validator = EntryValidator::new(&config, &dest);
527
528 validator
530 .validate_entry(
531 Path::new("file1.txt"),
532 &EntryType::File,
533 1024,
534 None,
535 Some(0o644),
536 )
537 .unwrap();
538
539 validator
540 .validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None)
541 .unwrap();
542
543 validator
544 .validate_entry(
545 Path::new("hardlink"),
546 &EntryType::Hardlink {
547 target: PathBuf::from("file1.txt"),
548 },
549 0,
550 None,
551 None,
552 )
553 .unwrap();
554
555 let report = validator.finish();
556 assert_eq!(report.files_validated, 1); assert_eq!(report.total_bytes, 1024);
558 assert_eq!(report.hardlinks_tracked, 1);
559 }
560
561 #[test]
563 fn test_empty_directory_validation() {
564 let temp = TempDir::new().expect("failed to create temp dir");
565 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
566 let config = SecurityConfig::default();
567 let mut validator = EntryValidator::new(&config, &dest);
568
569 let result = validator.validate_entry(
571 Path::new("empty_dir/"),
572 &EntryType::Directory,
573 0,
574 None,
575 None,
576 );
577
578 assert!(result.is_ok(), "empty directory should be valid");
579 let entry = result.unwrap();
580 assert!(
581 matches!(entry.entry_type, ValidatedEntryType::Directory),
582 "should be directory type"
583 );
584 assert!(entry.mode.is_none(), "directory should not have mode set");
585 }
586
587 #[test]
588 fn test_nested_empty_directories() {
589 let temp = TempDir::new().expect("failed to create temp dir");
590 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
591 let config = SecurityConfig::default();
592 let mut validator = EntryValidator::new(&config, &dest);
593
594 let dirs = ["a/", "a/b/", "a/b/c/"];
596 for dir in &dirs {
597 let result =
598 validator.validate_entry(Path::new(dir), &EntryType::Directory, 0, None, None);
599 assert!(result.is_ok(), "nested directory {dir} should be valid");
600 }
601
602 let report = validator.finish();
603 assert_eq!(
604 report.files_validated, 0,
605 "directories are not counted as files"
606 );
607 }
608
609 #[test]
611 fn test_validator_uses_references() {
612 let temp = TempDir::new().unwrap();
613 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
614 let config = SecurityConfig::default();
615
616 let validator = EntryValidator::new(&config, &dest);
618
619 assert_eq!(
621 config.max_file_size,
622 SecurityConfig::default().max_file_size
623 );
624 let _ = dest.as_path();
627
628 drop(validator);
630 }
631
632 #[test]
634 fn test_multiple_validators_share_config() {
635 let temp1 = TempDir::new().unwrap();
636 let temp2 = TempDir::new().unwrap();
637 let dest1 = DestDir::new(temp1.path().to_path_buf()).unwrap();
638 let dest2 = DestDir::new(temp2.path().to_path_buf()).unwrap();
639 let config = SecurityConfig::default();
640
641 let mut validator1 = EntryValidator::new(&config, &dest1);
643 let mut validator2 = EntryValidator::new(&config, &dest2);
644
645 let result1 = validator1.validate_entry(
647 Path::new("file1.txt"),
648 &EntryType::File,
649 1024,
650 None,
651 Some(0o644),
652 );
653 assert!(result1.is_ok());
654
655 let result2 = validator2.validate_entry(
656 Path::new("file2.txt"),
657 &EntryType::File,
658 2048,
659 None,
660 Some(0o644),
661 );
662 assert!(result2.is_ok());
663
664 assert_eq!(
666 config.max_file_size,
667 SecurityConfig::default().max_file_size
668 );
669 }
670}