1use std::path::Path;
7
8use crate::Result;
9use crate::SecurityConfig;
10use crate::formats::common::DirCache;
11use crate::security::context::ValidationContext;
12use crate::security::hardlink::HardlinkTracker;
13use crate::security::permissions::sanitize_permissions;
14use crate::security::quota::QuotaTracker;
15use crate::security::symlink::validate_symlink;
16use crate::security::zipbomb::validate_compression_ratio;
17use crate::types::DestDir;
18use crate::types::EntryType;
19use crate::types::SafePath;
20use crate::types::SafeSymlink;
21
22#[derive(Debug)]
26pub struct ValidatedEntry {
27 pub safe_path: SafePath,
29
30 pub entry_type: ValidatedEntryType,
32
33 pub mode: Option<u32>,
35}
36
37#[derive(Debug)]
39pub enum ValidatedEntryType {
40 File,
42
43 Directory,
45
46 Symlink(SafeSymlink),
48
49 Hardlink {
51 target: SafePath,
53 },
54}
55
56pub struct EntryValidator<'a> {
104 config: &'a SecurityConfig,
105 dest: &'a DestDir,
106 quota_tracker: QuotaTracker,
107 hardlink_tracker: HardlinkTracker,
108 symlink_seen: bool,
109}
110
111impl<'a> EntryValidator<'a> {
112 #[must_use]
114 pub fn new(config: &'a SecurityConfig, dest: &'a DestDir) -> Self {
115 Self {
116 config,
117 dest,
118 quota_tracker: QuotaTracker::new(),
119 hardlink_tracker: HardlinkTracker::new(),
120 symlink_seen: false,
121 }
122 }
123
124 pub fn validate_entry(
145 &mut self,
146 path: &Path,
147 entry_type: &EntryType,
148 uncompressed_size: u64,
149 compressed_size: Option<u64>,
150 mode: Option<u32>,
151 dir_cache: Option<&DirCache>,
152 ) -> Result<ValidatedEntry> {
153 let mut ctx = ValidationContext::new(self.config.allowed.symlinks);
154 if let Some(cache) = dir_cache {
155 ctx = ctx.with_dir_cache(cache);
156 }
157 if self.symlink_seen {
158 ctx.mark_symlink_seen();
159 }
160
161 let safe_path = SafePath::validate_with_context(path, self.dest, self.config, &ctx)?;
162
163 if matches!(entry_type, EntryType::File) {
164 self.quota_tracker
165 .record_file(uncompressed_size, self.config)?;
166 }
167
168 if let Some(compressed) = compressed_size {
169 validate_compression_ratio(compressed, uncompressed_size, self.config)?;
170 }
171
172 let (validated_type, sanitized_mode) = match entry_type {
173 EntryType::File => {
174 let sanitized = mode.map(|m| sanitize_permissions(m, self.config));
175 (ValidatedEntryType::File, sanitized)
176 }
177
178 EntryType::Directory => (ValidatedEntryType::Directory, None),
179
180 EntryType::Symlink { target } => {
181 let safe_symlink = validate_symlink(&safe_path, target, self.dest, self.config)?;
182 self.symlink_seen = true;
183 (ValidatedEntryType::Symlink(safe_symlink), None)
184 }
185
186 EntryType::Hardlink { target } => {
187 self.hardlink_tracker.validate_hardlink(
189 &safe_path,
190 target,
191 self.dest,
192 self.config,
193 )?;
194
195 let target_safe = SafePath::new_unchecked(target.clone());
198
199 (
200 ValidatedEntryType::Hardlink {
201 target: target_safe,
202 },
203 None,
204 )
205 }
206 };
207
208 Ok(ValidatedEntry {
209 safe_path,
210 entry_type: validated_type,
211 mode: sanitized_mode,
212 })
213 }
214
215 #[must_use]
220 pub fn finish(self) -> ValidationReport {
221 ValidationReport {
222 files_validated: self.quota_tracker.files_extracted(),
223 total_bytes: self.quota_tracker.bytes_written(),
224 hardlinks_tracked: self.hardlink_tracker.count(),
225 }
226 }
227}
228
229#[derive(Debug)]
231pub struct ValidationReport {
232 pub files_validated: usize,
234
235 pub total_bytes: u64,
237
238 pub hardlinks_tracked: usize,
240}
241
242#[cfg(test)]
243#[allow(
244 clippy::unwrap_used,
245 clippy::expect_used,
246 clippy::field_reassign_with_default
247)]
248mod tests {
249 use super::*;
250 use std::path::PathBuf;
251 use tempfile::TempDir;
252
253 #[test]
254 fn test_entry_validator_new() {
255 let temp = TempDir::new().expect("failed to create temp dir");
256 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
257 let config = SecurityConfig::default();
258 let validator = EntryValidator::new(&config, &dest);
259 let report = validator.finish();
260 assert_eq!(report.files_validated, 0);
261 assert_eq!(report.total_bytes, 0);
262 assert_eq!(report.hardlinks_tracked, 0);
263 }
264
265 #[test]
266 fn test_validate_file_entry() {
267 let temp = TempDir::new().expect("failed to create temp dir");
268 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
269 let config = SecurityConfig::default();
270 let mut validator = EntryValidator::new(&config, &dest);
271
272 let result = validator.validate_entry(
273 Path::new("file.txt"),
274 &EntryType::File,
275 1024,
276 None,
277 Some(0o644),
278 None,
279 );
280
281 assert!(result.is_ok());
282 let entry = result.unwrap();
283 assert_eq!(entry.safe_path.as_path(), Path::new("file.txt"));
284 assert!(matches!(entry.entry_type, ValidatedEntryType::File));
285 assert_eq!(entry.mode, Some(0o644));
286 }
287
288 #[test]
289 fn test_validate_directory_entry() {
290 let temp = TempDir::new().expect("failed to create temp dir");
291 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
292 let config = SecurityConfig::default();
293 let mut validator = EntryValidator::new(&config, &dest);
294
295 let result =
296 validator.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None);
297
298 assert!(result.is_ok());
299 let entry = result.unwrap();
300 assert!(matches!(entry.entry_type, ValidatedEntryType::Directory));
301 assert!(entry.mode.is_none());
302 }
303
304 #[test]
305 fn test_validate_path_traversal_rejected() {
306 let temp = TempDir::new().expect("failed to create temp dir");
307 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
308 let config = SecurityConfig::default();
309 let mut validator = EntryValidator::new(&config, &dest);
310
311 let result = validator.validate_entry(
312 Path::new("../etc/passwd"),
313 &EntryType::File,
314 1024,
315 None,
316 Some(0o644),
317 None,
318 );
319
320 assert!(result.is_err());
321 }
322
323 #[test]
324 fn test_quota_exceeded_file_size() {
325 let temp = TempDir::new().unwrap();
326 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
327 let mut config = SecurityConfig::default();
328 config.max_file_size = 100;
329 let mut validator = EntryValidator::new(&config, &dest);
330
331 let result = validator.validate_entry(
332 Path::new("large.txt"),
333 &EntryType::File,
334 1000,
335 None,
336 Some(0o644),
337 None,
338 );
339
340 assert!(result.is_err());
341 }
342
343 #[test]
344 fn test_quota_exceeded_file_count() {
345 let temp = TempDir::new().unwrap();
346 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
347 let mut config = SecurityConfig::default();
348 config.max_file_count = 2;
349 let mut validator = EntryValidator::new(&config, &dest);
350
351 assert!(
352 validator
353 .validate_entry(
354 Path::new("file1.txt"),
355 &EntryType::File,
356 100,
357 None,
358 Some(0o644),
359 None,
360 )
361 .is_ok()
362 );
363 assert!(
364 validator
365 .validate_entry(
366 Path::new("file2.txt"),
367 &EntryType::File,
368 100,
369 None,
370 Some(0o644),
371 None,
372 )
373 .is_ok()
374 );
375
376 let result = validator.validate_entry(
377 Path::new("file3.txt"),
378 &EntryType::File,
379 100,
380 None,
381 Some(0o644),
382 None,
383 );
384 assert!(result.is_err());
385 }
386
387 #[test]
388 fn test_zip_bomb_detected() {
389 let temp = TempDir::new().expect("failed to create temp dir");
390 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
391 let config = SecurityConfig::default();
392 let mut validator = EntryValidator::new(&config, &dest);
393
394 let result = validator.validate_entry(
395 Path::new("bomb.txt"),
396 &EntryType::File,
397 1_000_000,
398 Some(100),
399 Some(0o644),
400 None,
401 );
402
403 assert!(result.is_err());
404 }
405
406 #[test]
407 fn test_validation_report() {
408 let temp = TempDir::new().expect("failed to create temp dir");
409 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
410 let config = SecurityConfig::default();
411 let mut validator = EntryValidator::new(&config, &dest);
412
413 validator
414 .validate_entry(
415 Path::new("file1.txt"),
416 &EntryType::File,
417 1024,
418 None,
419 Some(0o644),
420 None,
421 )
422 .unwrap();
423
424 validator
425 .validate_entry(
426 Path::new("file2.txt"),
427 &EntryType::File,
428 2048,
429 None,
430 Some(0o644),
431 None,
432 )
433 .unwrap();
434
435 let report = validator.finish();
436 assert_eq!(report.files_validated, 2);
437 assert_eq!(report.total_bytes, 1024 + 2048);
438 }
439
440 #[test]
441 fn test_sanitize_permissions_setuid() {
442 let temp = TempDir::new().expect("failed to create temp dir");
443 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
444 let config = SecurityConfig::default();
445 let mut validator = EntryValidator::new(&config, &dest);
446
447 let result = validator.validate_entry(
448 Path::new("file.txt"),
449 &EntryType::File,
450 1024,
451 None,
452 Some(0o4755),
453 None,
454 );
455
456 assert!(result.is_ok());
457 let entry = result.unwrap();
458 assert_eq!(entry.mode, Some(0o755)); }
460
461 #[test]
462 fn test_symlink_validation() {
463 let temp = TempDir::new().unwrap();
464 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
465 let mut config = SecurityConfig::default();
466 config.allowed.symlinks = true;
467 let mut validator = EntryValidator::new(&config, &dest);
468
469 let result = validator.validate_entry(
470 Path::new("link"),
471 &EntryType::Symlink {
472 target: PathBuf::from("target.txt"),
473 },
474 0,
475 None,
476 None,
477 None,
478 );
479
480 assert!(result.is_ok());
481 let entry = result.unwrap();
482 assert!(matches!(entry.entry_type, ValidatedEntryType::Symlink(_)));
483 assert!(validator.symlink_seen);
484 }
485
486 #[test]
487 fn test_hardlink_validation() {
488 let temp = TempDir::new().unwrap();
489 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
490 let mut config = SecurityConfig::default();
491 config.allowed.hardlinks = true;
492 let mut validator = EntryValidator::new(&config, &dest);
493
494 let result = validator.validate_entry(
495 Path::new("link"),
496 &EntryType::Hardlink {
497 target: PathBuf::from("target.txt"),
498 },
499 0,
500 None,
501 None,
502 None,
503 );
504
505 assert!(result.is_ok());
506 let entry = result.unwrap();
507 assert!(matches!(
508 entry.entry_type,
509 ValidatedEntryType::Hardlink { .. }
510 ));
511 }
512
513 #[test]
514 fn test_multiple_entries_with_report() {
515 let temp = TempDir::new().unwrap();
516 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
517 let mut config = SecurityConfig::default();
518 config.allowed.hardlinks = true;
519 let mut validator = EntryValidator::new(&config, &dest);
520
521 validator
523 .validate_entry(
524 Path::new("file1.txt"),
525 &EntryType::File,
526 1024,
527 None,
528 Some(0o644),
529 None,
530 )
531 .unwrap();
532
533 validator
534 .validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None)
535 .unwrap();
536
537 validator
538 .validate_entry(
539 Path::new("hardlink"),
540 &EntryType::Hardlink {
541 target: PathBuf::from("file1.txt"),
542 },
543 0,
544 None,
545 None,
546 None,
547 )
548 .unwrap();
549
550 let report = validator.finish();
551 assert_eq!(report.files_validated, 1); assert_eq!(report.total_bytes, 1024);
553 assert_eq!(report.hardlinks_tracked, 1);
554 }
555
556 #[test]
558 fn test_empty_directory_validation() {
559 let temp = TempDir::new().expect("failed to create temp dir");
560 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
561 let config = SecurityConfig::default();
562 let mut validator = EntryValidator::new(&config, &dest);
563
564 let result = validator.validate_entry(
566 Path::new("empty_dir/"),
567 &EntryType::Directory,
568 0,
569 None,
570 None,
571 None,
572 );
573
574 assert!(result.is_ok(), "empty directory should be valid");
575 let entry = result.unwrap();
576 assert!(
577 matches!(entry.entry_type, ValidatedEntryType::Directory),
578 "should be directory type"
579 );
580 assert!(entry.mode.is_none(), "directory should not have mode set");
581 }
582
583 #[test]
584 fn test_nested_empty_directories() {
585 let temp = TempDir::new().expect("failed to create temp dir");
586 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
587 let config = SecurityConfig::default();
588 let mut validator = EntryValidator::new(&config, &dest);
589
590 let dirs = ["a/", "a/b/", "a/b/c/"];
592 for dir in &dirs {
593 let result = validator.validate_entry(
594 Path::new(dir),
595 &EntryType::Directory,
596 0,
597 None,
598 None,
599 None,
600 );
601 assert!(result.is_ok(), "nested directory {dir} should be valid");
602 }
603
604 let report = validator.finish();
605 assert_eq!(
606 report.files_validated, 0,
607 "directories are not counted as files"
608 );
609 }
610
611 #[test]
613 fn test_validator_uses_references() {
614 let temp = TempDir::new().unwrap();
615 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
616 let config = SecurityConfig::default();
617
618 let validator = EntryValidator::new(&config, &dest);
620
621 assert_eq!(
623 config.max_file_size,
624 SecurityConfig::default().max_file_size
625 );
626 let _ = dest.as_path();
629
630 drop(validator);
632 }
633
634 #[test]
636 fn test_multiple_validators_share_config() {
637 let temp1 = TempDir::new().unwrap();
638 let temp2 = TempDir::new().unwrap();
639 let dest1 = DestDir::new(temp1.path().to_path_buf()).unwrap();
640 let dest2 = DestDir::new(temp2.path().to_path_buf()).unwrap();
641 let config = SecurityConfig::default();
642
643 let mut validator1 = EntryValidator::new(&config, &dest1);
645 let mut validator2 = EntryValidator::new(&config, &dest2);
646
647 let result1 = validator1.validate_entry(
649 Path::new("file1.txt"),
650 &EntryType::File,
651 1024,
652 None,
653 Some(0o644),
654 None,
655 );
656 assert!(result1.is_ok());
657
658 let result2 = validator2.validate_entry(
659 Path::new("file2.txt"),
660 &EntryType::File,
661 2048,
662 None,
663 Some(0o644),
664 None,
665 );
666 assert!(result2.is_ok());
667
668 assert_eq!(
670 config.max_file_size,
671 SecurityConfig::default().max_file_size
672 );
673 }
674
675 #[test]
676 fn test_validate_entry_with_dir_cache() {
677 let temp = TempDir::new().expect("failed to create temp dir");
678 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
679 let config = SecurityConfig::default();
680 let mut validator = EntryValidator::new(&config, &dest);
681
682 let sub = dest.as_path().join("subdir");
683 let mut dir_cache = DirCache::new();
684 dir_cache.ensure_dir(&sub).expect("should create dir");
685
686 let result = validator.validate_entry(
687 Path::new("subdir/file.txt"),
688 &EntryType::File,
689 100,
690 None,
691 Some(0o644),
692 Some(&dir_cache),
693 );
694 assert!(
695 result.is_ok(),
696 "entry with dir_cache should validate: {result:?}"
697 );
698 }
699
700 #[test]
701 fn test_symlink_seen_flag_propagates() {
702 let temp = TempDir::new().unwrap();
703 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
704 let mut config = SecurityConfig::default();
705 config.allowed.symlinks = true;
706 let mut validator = EntryValidator::new(&config, &dest);
707
708 assert!(!validator.symlink_seen);
709
710 validator
712 .validate_entry(
713 Path::new("link"),
714 &EntryType::Symlink {
715 target: PathBuf::from("target.txt"),
716 },
717 0,
718 None,
719 None,
720 None,
721 )
722 .unwrap();
723
724 assert!(validator.symlink_seen);
725 }
726}