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 = if let Some(m) = mode {
175 Some(sanitize_permissions(safe_path.as_path(), m, self.config)?)
176 } else {
177 None
178 };
179 (ValidatedEntryType::File, sanitized)
180 }
181
182 EntryType::Directory => (ValidatedEntryType::Directory, None),
183
184 EntryType::Symlink { target } => {
185 let safe_symlink = validate_symlink(&safe_path, target, self.dest, self.config)?;
186 self.symlink_seen = true;
187 (ValidatedEntryType::Symlink(safe_symlink), None)
188 }
189
190 EntryType::Hardlink { target } => {
191 self.hardlink_tracker.validate_hardlink(
193 &safe_path,
194 target,
195 self.dest,
196 self.config,
197 )?;
198
199 let target_safe = SafePath::new_unchecked(target.clone());
202
203 (
204 ValidatedEntryType::Hardlink {
205 target: target_safe,
206 },
207 None,
208 )
209 }
210 };
211
212 Ok(ValidatedEntry {
213 safe_path,
214 entry_type: validated_type,
215 mode: sanitized_mode,
216 })
217 }
218
219 #[must_use]
224 pub fn finish(self) -> ValidationReport {
225 ValidationReport {
226 files_validated: self.quota_tracker.files_extracted(),
227 total_bytes: self.quota_tracker.bytes_written(),
228 hardlinks_tracked: self.hardlink_tracker.count(),
229 }
230 }
231}
232
233#[derive(Debug)]
235pub struct ValidationReport {
236 pub files_validated: usize,
238
239 pub total_bytes: u64,
241
242 pub hardlinks_tracked: usize,
244}
245
246#[cfg(test)]
247#[allow(
248 clippy::unwrap_used,
249 clippy::expect_used,
250 clippy::field_reassign_with_default
251)]
252mod tests {
253 use super::*;
254 use std::path::PathBuf;
255 use tempfile::TempDir;
256
257 #[test]
258 fn test_entry_validator_new() {
259 let temp = TempDir::new().expect("failed to create temp dir");
260 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
261 let config = SecurityConfig::default();
262 let validator = EntryValidator::new(&config, &dest);
263 let report = validator.finish();
264 assert_eq!(report.files_validated, 0);
265 assert_eq!(report.total_bytes, 0);
266 assert_eq!(report.hardlinks_tracked, 0);
267 }
268
269 #[test]
270 fn test_validate_file_entry() {
271 let temp = TempDir::new().expect("failed to create temp dir");
272 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
273 let config = SecurityConfig::default();
274 let mut validator = EntryValidator::new(&config, &dest);
275
276 let result = validator.validate_entry(
277 Path::new("file.txt"),
278 &EntryType::File,
279 1024,
280 None,
281 Some(0o644),
282 None,
283 );
284
285 assert!(result.is_ok());
286 let entry = result.unwrap();
287 assert_eq!(entry.safe_path.as_path(), Path::new("file.txt"));
288 assert!(matches!(entry.entry_type, ValidatedEntryType::File));
289 assert_eq!(entry.mode, Some(0o644));
290 }
291
292 #[test]
293 fn test_validate_directory_entry() {
294 let temp = TempDir::new().expect("failed to create temp dir");
295 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
296 let config = SecurityConfig::default();
297 let mut validator = EntryValidator::new(&config, &dest);
298
299 let result =
300 validator.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None);
301
302 assert!(result.is_ok());
303 let entry = result.unwrap();
304 assert!(matches!(entry.entry_type, ValidatedEntryType::Directory));
305 assert!(entry.mode.is_none());
306 }
307
308 #[test]
309 fn test_validate_path_traversal_rejected() {
310 let temp = TempDir::new().expect("failed to create temp dir");
311 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
312 let config = SecurityConfig::default();
313 let mut validator = EntryValidator::new(&config, &dest);
314
315 let result = validator.validate_entry(
316 Path::new("../etc/passwd"),
317 &EntryType::File,
318 1024,
319 None,
320 Some(0o644),
321 None,
322 );
323
324 assert!(result.is_err());
325 }
326
327 #[test]
328 fn test_quota_exceeded_file_size() {
329 let temp = TempDir::new().unwrap();
330 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
331 let mut config = SecurityConfig::default();
332 config.max_file_size = 100;
333 let mut validator = EntryValidator::new(&config, &dest);
334
335 let result = validator.validate_entry(
336 Path::new("large.txt"),
337 &EntryType::File,
338 1000,
339 None,
340 Some(0o644),
341 None,
342 );
343
344 assert!(result.is_err());
345 }
346
347 #[test]
348 fn test_quota_exceeded_file_count() {
349 let temp = TempDir::new().unwrap();
350 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
351 let mut config = SecurityConfig::default();
352 config.max_file_count = 2;
353 let mut validator = EntryValidator::new(&config, &dest);
354
355 assert!(
356 validator
357 .validate_entry(
358 Path::new("file1.txt"),
359 &EntryType::File,
360 100,
361 None,
362 Some(0o644),
363 None,
364 )
365 .is_ok()
366 );
367 assert!(
368 validator
369 .validate_entry(
370 Path::new("file2.txt"),
371 &EntryType::File,
372 100,
373 None,
374 Some(0o644),
375 None,
376 )
377 .is_ok()
378 );
379
380 let result = validator.validate_entry(
381 Path::new("file3.txt"),
382 &EntryType::File,
383 100,
384 None,
385 Some(0o644),
386 None,
387 );
388 assert!(result.is_err());
389 }
390
391 #[test]
392 fn test_zip_bomb_detected() {
393 let temp = TempDir::new().expect("failed to create temp dir");
394 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
395 let config = SecurityConfig::default();
396 let mut validator = EntryValidator::new(&config, &dest);
397
398 let result = validator.validate_entry(
399 Path::new("bomb.txt"),
400 &EntryType::File,
401 1_000_000,
402 Some(100),
403 Some(0o644),
404 None,
405 );
406
407 assert!(result.is_err());
408 }
409
410 #[test]
411 fn test_validation_report() {
412 let temp = TempDir::new().expect("failed to create temp dir");
413 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
414 let config = SecurityConfig::default();
415 let mut validator = EntryValidator::new(&config, &dest);
416
417 validator
418 .validate_entry(
419 Path::new("file1.txt"),
420 &EntryType::File,
421 1024,
422 None,
423 Some(0o644),
424 None,
425 )
426 .unwrap();
427
428 validator
429 .validate_entry(
430 Path::new("file2.txt"),
431 &EntryType::File,
432 2048,
433 None,
434 Some(0o644),
435 None,
436 )
437 .unwrap();
438
439 let report = validator.finish();
440 assert_eq!(report.files_validated, 2);
441 assert_eq!(report.total_bytes, 1024 + 2048);
442 }
443
444 #[test]
445 fn test_sanitize_permissions_setuid() {
446 let temp = TempDir::new().expect("failed to create temp dir");
447 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
448 let config = SecurityConfig::default();
449 let mut validator = EntryValidator::new(&config, &dest);
450
451 let result = validator.validate_entry(
452 Path::new("file.txt"),
453 &EntryType::File,
454 1024,
455 None,
456 Some(0o4755),
457 None,
458 );
459
460 assert!(result.is_ok());
461 let entry = result.unwrap();
462 assert_eq!(entry.mode, Some(0o755)); }
464
465 #[test]
466 fn test_symlink_validation() {
467 let temp = TempDir::new().unwrap();
468 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
469 let mut config = SecurityConfig::default();
470 config.allowed.symlinks = true;
471 let mut validator = EntryValidator::new(&config, &dest);
472
473 let result = validator.validate_entry(
474 Path::new("link"),
475 &EntryType::Symlink {
476 target: PathBuf::from("target.txt"),
477 },
478 0,
479 None,
480 None,
481 None,
482 );
483
484 assert!(result.is_ok());
485 let entry = result.unwrap();
486 assert!(matches!(entry.entry_type, ValidatedEntryType::Symlink(_)));
487 assert!(validator.symlink_seen);
488 }
489
490 #[test]
491 fn test_hardlink_validation() {
492 let temp = TempDir::new().unwrap();
493 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
494 let mut config = SecurityConfig::default();
495 config.allowed.hardlinks = true;
496 let mut validator = EntryValidator::new(&config, &dest);
497
498 let result = validator.validate_entry(
499 Path::new("link"),
500 &EntryType::Hardlink {
501 target: PathBuf::from("target.txt"),
502 },
503 0,
504 None,
505 None,
506 None,
507 );
508
509 assert!(result.is_ok());
510 let entry = result.unwrap();
511 assert!(matches!(
512 entry.entry_type,
513 ValidatedEntryType::Hardlink { .. }
514 ));
515 }
516
517 #[test]
518 fn test_multiple_entries_with_report() {
519 let temp = TempDir::new().unwrap();
520 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
521 let mut config = SecurityConfig::default();
522 config.allowed.hardlinks = true;
523 let mut validator = EntryValidator::new(&config, &dest);
524
525 validator
527 .validate_entry(
528 Path::new("file1.txt"),
529 &EntryType::File,
530 1024,
531 None,
532 Some(0o644),
533 None,
534 )
535 .unwrap();
536
537 validator
538 .validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None)
539 .unwrap();
540
541 validator
542 .validate_entry(
543 Path::new("hardlink"),
544 &EntryType::Hardlink {
545 target: PathBuf::from("file1.txt"),
546 },
547 0,
548 None,
549 None,
550 None,
551 )
552 .unwrap();
553
554 let report = validator.finish();
555 assert_eq!(report.files_validated, 1); assert_eq!(report.total_bytes, 1024);
557 assert_eq!(report.hardlinks_tracked, 1);
558 }
559
560 #[test]
562 fn test_empty_directory_validation() {
563 let temp = TempDir::new().expect("failed to create temp dir");
564 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
565 let config = SecurityConfig::default();
566 let mut validator = EntryValidator::new(&config, &dest);
567
568 let result = validator.validate_entry(
570 Path::new("empty_dir/"),
571 &EntryType::Directory,
572 0,
573 None,
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 = validator.validate_entry(
598 Path::new(dir),
599 &EntryType::Directory,
600 0,
601 None,
602 None,
603 None,
604 );
605 assert!(result.is_ok(), "nested directory {dir} should be valid");
606 }
607
608 let report = validator.finish();
609 assert_eq!(
610 report.files_validated, 0,
611 "directories are not counted as files"
612 );
613 }
614
615 #[test]
617 fn test_validator_uses_references() {
618 let temp = TempDir::new().unwrap();
619 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
620 let config = SecurityConfig::default();
621
622 let validator = EntryValidator::new(&config, &dest);
624
625 assert_eq!(
627 config.max_file_size,
628 SecurityConfig::default().max_file_size
629 );
630 let _ = dest.as_path();
633
634 drop(validator);
636 }
637
638 #[test]
640 fn test_multiple_validators_share_config() {
641 let temp1 = TempDir::new().unwrap();
642 let temp2 = TempDir::new().unwrap();
643 let dest1 = DestDir::new(temp1.path().to_path_buf()).unwrap();
644 let dest2 = DestDir::new(temp2.path().to_path_buf()).unwrap();
645 let config = SecurityConfig::default();
646
647 let mut validator1 = EntryValidator::new(&config, &dest1);
649 let mut validator2 = EntryValidator::new(&config, &dest2);
650
651 let result1 = validator1.validate_entry(
653 Path::new("file1.txt"),
654 &EntryType::File,
655 1024,
656 None,
657 Some(0o644),
658 None,
659 );
660 assert!(result1.is_ok());
661
662 let result2 = validator2.validate_entry(
663 Path::new("file2.txt"),
664 &EntryType::File,
665 2048,
666 None,
667 Some(0o644),
668 None,
669 );
670 assert!(result2.is_ok());
671
672 assert_eq!(
674 config.max_file_size,
675 SecurityConfig::default().max_file_size
676 );
677 }
678
679 #[test]
680 fn test_validate_entry_with_dir_cache() {
681 let temp = TempDir::new().expect("failed to create temp dir");
682 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
683 let config = SecurityConfig::default();
684 let mut validator = EntryValidator::new(&config, &dest);
685
686 let sub = dest.as_path().join("subdir");
687 let mut dir_cache = DirCache::new();
688 dir_cache.ensure_dir(&sub).expect("should create dir");
689
690 let result = validator.validate_entry(
691 Path::new("subdir/file.txt"),
692 &EntryType::File,
693 100,
694 None,
695 Some(0o644),
696 Some(&dir_cache),
697 );
698 assert!(
699 result.is_ok(),
700 "entry with dir_cache should validate: {result:?}"
701 );
702 }
703
704 #[test]
705 fn test_symlink_seen_flag_propagates() {
706 let temp = TempDir::new().unwrap();
707 let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
708 let mut config = SecurityConfig::default();
709 config.allowed.symlinks = true;
710 let mut validator = EntryValidator::new(&config, &dest);
711
712 assert!(!validator.symlink_seen);
713
714 validator
716 .validate_entry(
717 Path::new("link"),
718 &EntryType::Symlink {
719 target: PathBuf::from("target.txt"),
720 },
721 0,
722 None,
723 None,
724 None,
725 )
726 .unwrap();
727
728 assert!(validator.symlink_seen);
729 }
730}