wow_mpq/
security.rs

1//! Security validation module for MPQ archives
2//!
3//! This module provides comprehensive input validation and security controls
4//! to prevent common attack vectors when parsing untrusted MPQ archives:
5//!
6//! - Header field validation to prevent integer overflow
7//! - Table size limits to prevent memory exhaustion
8//! - File path sanitization to prevent directory traversal
9//! - Offset bounds checking to ensure reads stay within archive
10//! - Advanced compression bomb detection with adaptive limits
11//! - Decompression progress monitoring with resource protection
12//! - Pattern analysis for malicious archive structures
13//! - Checksum verification where possible
14//!
15//! All validation functions follow a fail-safe approach: when in doubt, reject.
16
17use crate::{Error, Result};
18use std::path::{Component, Path};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
21use std::time::{Duration, Instant};
22
23/// Security limits for various MPQ structures
24#[derive(Debug, Clone)]
25pub struct SecurityLimits {
26    /// Maximum allowed archive size (default: 4GB)
27    pub max_archive_size: u64,
28    /// Maximum allowed hash table entries (default: 1M)
29    pub max_hash_entries: u32,
30    /// Maximum allowed block table entries (default: 1M)
31    pub max_block_entries: u32,
32    /// Maximum allowed sector size shift (default: 20 for 512MB sectors)
33    pub max_sector_shift: u16,
34    /// Maximum allowed file path length (default: 260 chars)
35    pub max_path_length: usize,
36    /// Maximum allowed compression ratio (default: 1000:1)
37    pub max_compression_ratio: u32,
38    /// Maximum allowed decompressed size per file (default: 100MB)
39    pub max_decompressed_size: u64,
40    /// Maximum allowed number of files in archive (default: 100k)
41    pub max_file_count: u32,
42    /// Maximum total decompressed bytes per session (default: 1GB)
43    pub max_session_decompressed: u64,
44    /// Maximum decompression time per file (default: 30 seconds)
45    pub max_decompression_time: Duration,
46    /// Enable pattern-based compression bomb detection (default: true)
47    pub enable_pattern_detection: bool,
48    /// Adaptive compression ratio limits (default: true)
49    pub enable_adaptive_limits: bool,
50}
51
52impl Default for SecurityLimits {
53    fn default() -> Self {
54        Self {
55            max_archive_size: 4 * 1024 * 1024 * 1024, // 4GB
56            max_hash_entries: 1_000_000,
57            max_block_entries: 1_000_000,
58            max_sector_shift: 20, // 512MB max sector size
59            max_path_length: 260, // Windows MAX_PATH
60            max_compression_ratio: 1000,
61            max_decompressed_size: 100 * 1024 * 1024, // 100MB
62            max_file_count: 100_000,
63            max_session_decompressed: 1024 * 1024 * 1024, // 1GB
64            max_decompression_time: Duration::from_secs(30),
65            enable_pattern_detection: true,
66            enable_adaptive_limits: true,
67        }
68    }
69}
70
71/// Session tracker for tracking cumulative decompression across multiple files
72#[derive(Debug, Clone)]
73pub struct SessionTracker {
74    /// Total bytes decompressed in this session
75    pub total_decompressed: Arc<AtomicU64>,
76    /// Number of files decompressed
77    pub files_decompressed: Arc<AtomicUsize>,
78    /// Session start time
79    pub session_start: Instant,
80}
81
82impl Default for SessionTracker {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl SessionTracker {
89    /// Create a new session tracker
90    pub fn new() -> Self {
91        Self {
92            total_decompressed: Arc::new(AtomicU64::new(0)),
93            files_decompressed: Arc::new(AtomicUsize::new(0)),
94            session_start: Instant::now(),
95        }
96    }
97
98    /// Record a successful decompression
99    pub fn record_decompression(&self, bytes: u64) {
100        self.total_decompressed.fetch_add(bytes, Ordering::Relaxed);
101        self.files_decompressed.fetch_add(1, Ordering::Relaxed);
102    }
103
104    /// Get current session statistics
105    pub fn get_stats(&self) -> (u64, usize, Duration) {
106        (
107            self.total_decompressed.load(Ordering::Relaxed),
108            self.files_decompressed.load(Ordering::Relaxed),
109            self.session_start.elapsed(),
110        )
111    }
112
113    /// Check if session limits are exceeded
114    pub fn check_session_limits(&self, limits: &SecurityLimits) -> Result<()> {
115        let total = self.total_decompressed.load(Ordering::Relaxed);
116        if total > limits.max_session_decompressed {
117            return Err(Error::resource_exhaustion(
118                "Session decompression limit exceeded - potential resource exhaustion attack",
119            ));
120        }
121        Ok(())
122    }
123
124    /// Check if session limits would be exceeded with additional bytes
125    pub fn check_session_limits_with_addition(
126        &self,
127        additional_bytes: u64,
128        limits: &SecurityLimits,
129    ) -> Result<()> {
130        let current_total = self.total_decompressed.load(Ordering::Relaxed);
131        let projected_total = current_total.saturating_add(additional_bytes);
132        if projected_total > limits.max_session_decompressed {
133            return Err(Error::resource_exhaustion(
134                "Session decompression limit would be exceeded - potential resource exhaustion attack",
135            ));
136        }
137        Ok(())
138    }
139}
140
141/// Decompression monitor for tracking progress during decompression
142#[derive(Debug)]
143pub struct DecompressionMonitor {
144    /// Maximum allowed decompressed size
145    pub max_size: u64,
146    /// Maximum allowed decompression time
147    pub max_time: Duration,
148    /// Start time of decompression
149    pub start_time: Instant,
150    /// Current bytes decompressed (updated during decompression)
151    pub bytes_decompressed: Arc<AtomicU64>,
152    /// Whether decompression should be cancelled
153    pub should_cancel: Arc<AtomicU64>,
154}
155
156impl DecompressionMonitor {
157    /// Create a new decompression monitor
158    pub fn new(max_size: u64, max_time: Duration) -> Self {
159        Self {
160            max_size,
161            max_time,
162            start_time: Instant::now(),
163            bytes_decompressed: Arc::new(AtomicU64::new(0)),
164            should_cancel: Arc::new(AtomicU64::new(0)),
165        }
166    }
167
168    /// Check if decompression should continue
169    pub fn check_progress(&self, current_output_size: u64) -> Result<()> {
170        // Check size limits
171        if current_output_size > self.max_size {
172            return Err(Error::resource_exhaustion(
173                "Decompression size limit exceeded - potential compression bomb",
174            ));
175        }
176
177        // Check time limits
178        if self.start_time.elapsed() > self.max_time {
179            return Err(Error::resource_exhaustion(
180                "Decompression time limit exceeded - potential DoS attack",
181            ));
182        }
183
184        // Check if cancellation was requested
185        if self.should_cancel.load(Ordering::Relaxed) != 0 {
186            return Err(Error::resource_exhaustion(
187                "Decompression cancelled due to security limits",
188            ));
189        }
190
191        // Update current progress
192        self.bytes_decompressed
193            .store(current_output_size, Ordering::Relaxed);
194
195        Ok(())
196    }
197
198    /// Request cancellation of decompression
199    pub fn request_cancellation(&self) {
200        self.should_cancel.store(1, Ordering::Relaxed);
201    }
202
203    /// Get current statistics
204    pub fn get_stats(&self) -> (u64, Duration) {
205        (
206            self.bytes_decompressed.load(Ordering::Relaxed),
207            self.start_time.elapsed(),
208        )
209    }
210}
211
212/// Adaptive compression ratio calculator based on file characteristics
213#[derive(Debug, Clone)]
214pub struct AdaptiveCompressionLimits {
215    /// Base compression ratio limit
216    pub base_limit: u32,
217    /// Whether to enable adaptive limits
218    pub enabled: bool,
219}
220
221impl AdaptiveCompressionLimits {
222    /// Create new adaptive limits
223    pub fn new(base_limit: u32, enabled: bool) -> Self {
224        Self {
225            base_limit,
226            enabled,
227        }
228    }
229
230    /// Calculate compression ratio limit based on file characteristics
231    pub fn calculate_limit(&self, compressed_size: u64, compression_method: u8) -> u32 {
232        if !self.enabled {
233            return self.base_limit;
234        }
235
236        // Adaptive limits based on compressed file size
237        let size_based_limit = match compressed_size {
238            // Very small files can have high ratios due to format overhead
239            0..=512 => self.base_limit * 10, // Up to 10000:1 for tiny files
240            513..=4096 => self.base_limit * 5, // Up to 5000:1 for small files
241            4097..=65536 => self.base_limit * 2, // Up to 2000:1 for medium files
242            65537..=1048576 => self.base_limit, // Base limit for large files
243            _ => self.base_limit / 2,        // Stricter limit for very large files
244        };
245
246        // Adjust based on compression method capabilities
247        let method_based_limit = match compression_method {
248            // Text compression methods can achieve higher ratios legitimately
249            0x02 => size_based_limit * 2,        // Zlib - good for text
250            0x10 => size_based_limit * 3,        // BZip2 - excellent for text
251            0x12 => size_based_limit * 4,        // LZMA - best for text
252            0x20 => size_based_limit / 2,        // Sparse - should be moderate
253            0x08 => size_based_limit,            // Implode - moderate compression
254            0x01 => size_based_limit / 2,        // Huffman - lower ratios expected
255            0x40 | 0x80 => size_based_limit * 2, // ADPCM - audio can compress well
256            _ => size_based_limit,               // Unknown methods use base calculation
257        };
258
259        // Ensure we don't go below a reasonable minimum or above a hard maximum
260        method_based_limit.clamp(50, 50000)
261    }
262}
263
264impl SecurityLimits {
265    /// Create new security limits with stricter settings
266    pub fn strict() -> Self {
267        Self {
268            max_archive_size: 1024 * 1024 * 1024, // 1GB
269            max_hash_entries: 100_000,
270            max_block_entries: 100_000,
271            max_sector_shift: 16, // 32MB max sector size
272            max_path_length: 128,
273            max_compression_ratio: 100,
274            max_decompressed_size: 10 * 1024 * 1024, // 10MB
275            max_file_count: 10_000,
276            max_session_decompressed: 100 * 1024 * 1024, // 100MB
277            max_decompression_time: Duration::from_secs(10),
278            enable_pattern_detection: true,
279            enable_adaptive_limits: true,
280        }
281    }
282
283    /// Create new security limits with more permissive settings
284    pub fn permissive() -> Self {
285        Self {
286            max_archive_size: 16 * 1024 * 1024 * 1024, // 16GB
287            max_hash_entries: 10_000_000,
288            max_block_entries: 10_000_000,
289            max_sector_shift: 24, // 8GB max sector size
290            max_path_length: 1024,
291            max_compression_ratio: 10000,
292            max_decompressed_size: 1024 * 1024 * 1024, // 1GB
293            max_file_count: 1_000_000,
294            max_session_decompressed: 16 * 1024 * 1024 * 1024, // 16GB
295            max_decompression_time: Duration::from_secs(300),
296            enable_pattern_detection: true,
297            enable_adaptive_limits: true,
298        }
299    }
300}
301
302/// Validate MPQ header fields for security vulnerabilities
303#[allow(clippy::too_many_arguments)]
304pub fn validate_header_security(
305    signature: u32,
306    header_size: u32,
307    archive_size: u32,
308    format_version: u16,
309    sector_shift: u16,
310    hash_table_offset: u32,
311    block_table_offset: u32,
312    hash_table_size: u32,
313    block_table_size: u32,
314    limits: &SecurityLimits,
315) -> Result<()> {
316    // Validate signature
317    if signature != crate::signatures::MPQ_ARCHIVE {
318        return Err(Error::invalid_format(
319            "Invalid MPQ signature - not a valid MPQ archive",
320        ));
321    }
322
323    // Validate header size
324    if !(32..=1024).contains(&header_size) {
325        return Err(Error::invalid_format(
326            "Invalid header size - must be between 32 and 1024 bytes",
327        ));
328    }
329
330    // Validate archive size
331    if archive_size == 0 || archive_size as u64 > limits.max_archive_size {
332        return Err(Error::invalid_format(
333            "Invalid archive size - too large or zero",
334        ));
335    }
336
337    // Validate format version
338    if format_version > 4 {
339        return Err(Error::UnsupportedVersion(format_version));
340    }
341
342    // Validate sector shift
343    if sector_shift > limits.max_sector_shift {
344        return Err(Error::invalid_format(
345            "Invalid sector shift - would create excessive sector size",
346        ));
347    }
348
349    // Validate table offsets are within archive
350    if hash_table_offset >= archive_size {
351        return Err(Error::invalid_format(
352            "Hash table offset exceeds archive size",
353        ));
354    }
355
356    // For empty archives (especially v4), allow block table offset to equal archive size
357    // Empty archives may have no actual block table data
358    if block_table_size == 0 && block_table_offset == archive_size {
359        // Empty archive - this is valid
360    } else if block_table_offset > archive_size {
361        return Err(Error::invalid_format(
362            "Block table offset exceeds archive size",
363        ));
364    }
365
366    // Validate table sizes
367    if hash_table_size > limits.max_hash_entries {
368        return Err(Error::resource_exhaustion(
369            "Hash table too large - potential memory exhaustion attack",
370        ));
371    }
372
373    if block_table_size > limits.max_block_entries {
374        return Err(Error::invalid_format(
375            "Block table too large - potential memory exhaustion attack",
376        ));
377    }
378
379    // Check for integer overflow in table calculations
380    let hash_table_bytes = hash_table_size
381        .checked_mul(16) // Hash entry is 16 bytes
382        .ok_or_else(|| Error::invalid_format("Hash table size causes integer overflow"))?;
383
384    let block_table_bytes = block_table_size
385        .checked_mul(16) // Block entry is 16 bytes
386        .ok_or_else(|| Error::invalid_format("Block table size causes integer overflow"))?;
387
388    // Ensure tables fit within archive with some tolerance for creation-time
389    if let Some(end_pos) = hash_table_offset.checked_add(hash_table_bytes) {
390        // Allow small tolerance for newly created archives
391        if end_pos > archive_size.saturating_add(65536) {
392            return Err(Error::invalid_format(
393                "Hash table extends beyond archive bounds",
394            ));
395        }
396    } else {
397        return Err(Error::invalid_format(
398            "Hash table size calculation overflows",
399        ));
400    }
401
402    // Allow some tolerance for newly created archives where the file size may not match header yet
403    if let Some(end_pos) = block_table_offset.checked_add(block_table_bytes) {
404        // For new archives, allow the table to extend slightly beyond declared archive_size
405        // but not beyond reasonable limits (e.g., 64KB tolerance for headers/metadata)
406        if end_pos > archive_size.saturating_add(65536) {
407            return Err(Error::invalid_format(
408                "Block table extends beyond archive bounds",
409            ));
410        }
411    } else {
412        return Err(Error::invalid_format(
413            "Block table size calculation overflows",
414        ));
415    }
416
417    // Ensure hash table size is power of 2 (MPQ requirement)
418    if hash_table_size == 0 || !crate::is_power_of_two(hash_table_size) {
419        return Err(Error::invalid_format(
420            "Hash table size must be a non-zero power of 2",
421        ));
422    }
423
424    Ok(())
425}
426
427/// Validate file path for directory traversal attacks
428pub fn validate_file_path(path: &str, limits: &SecurityLimits) -> Result<()> {
429    // Check path length
430    if path.len() > limits.max_path_length {
431        return Err(Error::invalid_format(
432            "File path too long - potential buffer overflow",
433        ));
434    }
435
436    // Check for empty path
437    if path.is_empty() {
438        return Err(Error::invalid_format("Empty file path not allowed"));
439    }
440
441    // Check for null bytes (can cause issues in C FFI)
442    if path.contains('\0') {
443        return Err(Error::invalid_format(
444            "File path contains null bytes - potential security issue",
445        ));
446    }
447
448    // Normalize and validate path components
449    let normalized_path = Path::new(path);
450
451    for component in normalized_path.components() {
452        match component {
453            // Reject parent directory references
454            Component::ParentDir => {
455                return Err(Error::directory_traversal(
456                    "File path contains parent directory reference",
457                ));
458            }
459            // Reject absolute paths
460            Component::RootDir => {
461                return Err(Error::invalid_format(
462                    "Absolute file paths not allowed in MPQ archives",
463                ));
464            }
465            // Check normal path components
466            Component::Normal(name) => {
467                let name_str = name.to_string_lossy();
468
469                // Check for Windows reserved names
470                let reserved_names = [
471                    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6",
472                    "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7",
473                    "LPT8", "LPT9",
474                ];
475
476                let name_upper = name_str.to_uppercase();
477                // Check if name matches reserved name exactly or with extension
478                for &reserved in &reserved_names {
479                    if name_upper == reserved || name_upper.starts_with(&format!("{reserved}.")) {
480                        return Err(Error::invalid_format(
481                            "File path contains Windows reserved name",
482                        ));
483                    }
484                }
485
486                // Check for dangerous characters
487                for ch in name_str.chars() {
488                    match ch {
489                        // Control characters
490                        '\0'..='\x1f' | '\x7f' => {
491                            return Err(Error::invalid_format(
492                                "File path contains control characters",
493                            ));
494                        }
495                        // Dangerous characters on Windows
496                        '<' | '>' | '|' | '"' | '?' | '*' => {
497                            return Err(Error::invalid_format(
498                                "File path contains dangerous characters",
499                            ));
500                        }
501                        _ => {} // Character is OK
502                    }
503                }
504            }
505            _ => {} // Other components (CurDir) are generally OK
506        }
507    }
508
509    Ok(())
510}
511
512/// Validate file offset and size bounds within archive
513pub fn validate_file_bounds(
514    file_offset: u64,
515    file_size: u64,
516    compressed_size: u64,
517    archive_size: u64,
518    limits: &SecurityLimits,
519) -> Result<()> {
520    // Check for zero sizes (usually invalid)
521    if compressed_size == 0 {
522        return Err(Error::invalid_format("Compressed file size cannot be zero"));
523    }
524
525    // Validate decompressed size limit
526    if file_size > limits.max_decompressed_size {
527        return Err(Error::resource_exhaustion(
528            "File size exceeds maximum allowed limit",
529        ));
530    }
531
532    // Check file bounds within archive
533    let file_end = file_offset
534        .checked_add(compressed_size)
535        .ok_or_else(|| Error::invalid_format("File offset causes integer overflow"))?;
536
537    if file_end > archive_size {
538        return Err(Error::invalid_format(
539            "File data extends beyond archive bounds",
540        ));
541    }
542
543    // Validate compression ratio to detect zip bombs
544    if file_size > 0 && compressed_size > 0 {
545        let compression_ratio = file_size / compressed_size;
546        if compression_ratio > limits.max_compression_ratio as u64 {
547            let ratio = file_size / compressed_size;
548            return Err(Error::compression_bomb(
549                ratio,
550                limits.max_compression_ratio as u64,
551            ));
552        }
553    }
554
555    Ok(())
556}
557
558/// Validate sector data for consistency
559pub fn validate_sector_data(
560    sector_index: u32,
561    sector_size: u32,
562    data_size: usize,
563    expected_crc: Option<u32>,
564) -> Result<()> {
565    // Validate sector size is reasonable
566    if sector_size == 0 || sector_size > 16 * 1024 * 1024 {
567        return Err(Error::invalid_format(
568            "Invalid sector size - must be between 1 byte and 16MB",
569        ));
570    }
571
572    // Check data size consistency
573    if data_size > sector_size as usize {
574        return Err(Error::invalid_format(
575            "Sector data size exceeds sector size limit",
576        ));
577    }
578
579    // Validate sector index for reasonable bounds
580    if sector_index > 1_000_000 {
581        return Err(Error::invalid_format(
582            "Sector index too high - potential memory exhaustion",
583        ));
584    }
585
586    // If CRC is provided, we could validate it here
587    // This is placeholder for future CRC validation
588    if let Some(_crc) = expected_crc {
589        // TODO: Implement CRC validation when sector data is available
590    }
591
592    Ok(())
593}
594
595/// Validate table entry for security issues
596pub fn validate_table_entry(
597    entry_index: u32,
598    file_offset: u32,
599    file_size: u32,
600    compressed_size: u32,
601    archive_size: u32,
602    limits: &SecurityLimits,
603) -> Result<()> {
604    // Validate entry index
605    if entry_index >= limits.max_file_count {
606        return Err(Error::invalid_format(
607            "Table entry index too high - potential memory exhaustion",
608        ));
609    }
610
611    // Use the file bounds validation
612    validate_file_bounds(
613        file_offset as u64,
614        file_size as u64,
615        compressed_size as u64,
616        archive_size as u64,
617        limits,
618    )?;
619
620    // Additional validation for compressed vs uncompressed size relationship
621    if compressed_size > file_size && file_size > 0 {
622        // This can happen with small files where compression overhead exceeds savings
623        // But if the difference is too large, it might be suspicious
624        let size_diff = compressed_size - file_size;
625        if size_diff > 1024 && size_diff > file_size {
626            return Err(Error::invalid_format(
627                "Compressed size significantly larger than uncompressed - suspicious",
628            ));
629        }
630    }
631
632    Ok(())
633}
634
635/// Advanced compression bomb detection using pattern analysis
636pub fn detect_compression_bomb_patterns(
637    compressed_size: u64,
638    decompressed_size: u64,
639    compression_method: u8,
640    file_path: Option<&str>,
641    limits: &SecurityLimits,
642) -> Result<()> {
643    if !limits.enable_pattern_detection {
644        return Ok(());
645    }
646
647    // Calculate adaptive compression ratio limit
648    let adaptive_limits =
649        AdaptiveCompressionLimits::new(limits.max_compression_ratio, limits.enable_adaptive_limits);
650    let max_ratio = adaptive_limits.calculate_limit(compressed_size, compression_method);
651
652    // Check compression ratio with adaptive limits
653    if decompressed_size > 0 && compressed_size > 0 {
654        let ratio = decompressed_size / compressed_size;
655        if ratio > max_ratio as u64 {
656            return Err(Error::compression_bomb(ratio, max_ratio as u64));
657        }
658    }
659
660    // Pattern 1: Extremely small compressed size with large decompressed size
661    if compressed_size < 100 && decompressed_size > 10 * 1024 * 1024 {
662        return Err(Error::malicious_content(
663            "Suspicious compression pattern: tiny compressed data with huge output",
664        ));
665    }
666
667    // Pattern 2: Nested archive detection (by file extension)
668    if let Some(path) = file_path {
669        let path_lower = path.to_lowercase();
670        if (path_lower.ends_with(".mpq")
671            || path_lower.ends_with(".zip")
672            || path_lower.ends_with(".rar")
673            || path_lower.ends_with(".7z"))
674            && decompressed_size > 50 * 1024 * 1024
675        // Large nested archive
676        {
677            return Err(Error::malicious_content(
678                "Suspicious nested archive with large decompressed size",
679            ));
680        }
681    }
682
683    // Pattern 3: Multiple compression methods with suspicious ratios
684    if compression_method > 0x80 {
685        // Multiple compression flags
686        let expected_multi_ratio = max_ratio / 2; // Lower expectation for multi-compression
687        if decompressed_size > 0 && compressed_size > 0 {
688            let ratio = decompressed_size / compressed_size;
689            if ratio > expected_multi_ratio as u64 {
690                return Err(Error::compression_bomb(ratio, expected_multi_ratio as u64));
691            }
692        }
693    }
694
695    // Pattern 4: Decompressed size approaching system limits
696    if decompressed_size > limits.max_decompressed_size * 3 / 4 {
697        log::warn!(
698            "Large decompression detected: {} bytes ({}% of limit)",
699            decompressed_size,
700            (decompressed_size * 100) / limits.max_decompressed_size
701        );
702    }
703
704    Ok(())
705}
706
707/// Validate decompression operation with comprehensive security checks
708pub fn validate_decompression_operation(
709    compressed_size: u64,
710    expected_decompressed_size: u64,
711    compression_method: u8,
712    file_path: Option<&str>,
713    session_tracker: &SessionTracker,
714    limits: &SecurityLimits,
715) -> Result<DecompressionMonitor> {
716    // Check session limits first (current + projected)
717    session_tracker.check_session_limits_with_addition(expected_decompressed_size, limits)?;
718
719    // Validate basic file bounds
720    validate_file_bounds(
721        0, // offset not relevant for this check
722        expected_decompressed_size,
723        compressed_size,
724        u64::MAX, // archive size not relevant for this check
725        limits,
726    )?;
727
728    // Run pattern-based compression bomb detection
729    detect_compression_bomb_patterns(
730        compressed_size,
731        expected_decompressed_size,
732        compression_method,
733        file_path,
734        limits,
735    )?;
736
737    // Create decompression monitor
738    let monitor = DecompressionMonitor::new(
739        expected_decompressed_size.min(limits.max_decompressed_size),
740        limits.max_decompression_time,
741    );
742
743    // Log security-relevant decompression attempts
744    if expected_decompressed_size > 10 * 1024 * 1024 {
745        // Log files > 10MB
746        log::info!(
747            "Large decompression: {} -> {} bytes ({}:1 ratio) method=0x{:02X} path={}",
748            compressed_size,
749            expected_decompressed_size,
750            if compressed_size > 0 {
751                expected_decompressed_size / compressed_size
752            } else {
753                0
754            },
755            compression_method,
756            file_path.unwrap_or("<unknown>")
757        );
758    }
759
760    Ok(monitor)
761}
762
763/// Check if decompression output size is within expected bounds
764pub fn validate_decompression_result(
765    expected_size: u64,
766    actual_size: u64,
767    tolerance_percent: u8,
768) -> Result<()> {
769    if expected_size == 0 {
770        return Ok(()); // Cannot validate if expected size is unknown
771    }
772
773    let tolerance = (expected_size * tolerance_percent as u64) / 100;
774    let min_size = expected_size.saturating_sub(tolerance);
775    let max_size = expected_size.saturating_add(tolerance);
776
777    if actual_size < min_size || actual_size > max_size {
778        return Err(Error::compression(format!(
779            "Decompression size mismatch: expected {}, got {} (±{}% tolerance)",
780            expected_size, actual_size, tolerance_percent
781        )));
782    }
783
784    Ok(())
785}
786
787/// Create a security-aware error for validation failures
788pub fn security_error<S: Into<String>>(message: S) -> Error {
789    Error::security_violation(message.into())
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    #[test]
797    fn test_security_limits_defaults() {
798        let limits = SecurityLimits::default();
799        assert_eq!(limits.max_archive_size, 4 * 1024 * 1024 * 1024);
800        assert_eq!(limits.max_hash_entries, 1_000_000);
801        assert_eq!(limits.max_compression_ratio, 1000);
802    }
803
804    #[test]
805    fn test_valid_header() {
806        let limits = SecurityLimits::default();
807
808        let result = validate_header_security(
809            crate::signatures::MPQ_ARCHIVE,
810            32,          // header_size
811            1024 * 1024, // archive_size (1MB)
812            1,           // format_version
813            3,           // sector_shift (4KB sectors)
814            32,          // hash_table_offset
815            512,         // block_table_offset
816            16,          // hash_table_size (16 entries, power of 2)
817            16,          // block_table_size
818            &limits,
819        );
820
821        assert!(result.is_ok());
822    }
823
824    #[test]
825    fn test_invalid_signature() {
826        let limits = SecurityLimits::default();
827
828        let result = validate_header_security(
829            0x12345678, // Invalid signature
830            32,
831            1024 * 1024,
832            1,
833            3,
834            32,
835            512,
836            16,
837            16,
838            &limits,
839        );
840
841        assert!(result.is_err());
842        assert!(
843            result
844                .unwrap_err()
845                .to_string()
846                .contains("Invalid MPQ signature")
847        );
848    }
849
850    #[test]
851    fn test_oversized_tables() {
852        let limits = SecurityLimits::default();
853
854        let result = validate_header_security(
855            crate::signatures::MPQ_ARCHIVE,
856            32,
857            1024 * 1024,
858            1,
859            3,
860            32,
861            512,
862            limits.max_hash_entries + 1, // Oversized hash table
863            16,
864            &limits,
865        );
866
867        assert!(result.is_err());
868        assert!(
869            result
870                .unwrap_err()
871                .to_string()
872                .contains("Hash table too large")
873        );
874    }
875
876    #[test]
877    fn test_valid_file_path() {
878        let limits = SecurityLimits::default();
879
880        assert!(validate_file_path("data/models/character.m2", &limits).is_ok());
881        assert!(validate_file_path("sounds/music/theme.mp3", &limits).is_ok());
882        assert!(validate_file_path("world/maps/area.adt", &limits).is_ok());
883    }
884
885    #[test]
886    fn test_directory_traversal_attack() {
887        let limits = SecurityLimits::default();
888
889        assert!(validate_file_path("../../../etc/passwd", &limits).is_err());
890        assert!(validate_file_path("data/../../../secret", &limits).is_err());
891        assert!(validate_file_path("/absolute/path", &limits).is_err());
892    }
893
894    #[test]
895    fn test_dangerous_file_names() {
896        let limits = SecurityLimits::default();
897
898        assert!(validate_file_path("data/CON", &limits).is_err());
899        assert!(validate_file_path("data/PRN.txt", &limits).is_err());
900        assert!(validate_file_path("data/file<script>", &limits).is_err());
901        assert!(validate_file_path("data/file\x00.txt", &limits).is_err());
902    }
903
904    #[test]
905    fn test_file_bounds_validation() {
906        let limits = SecurityLimits::default();
907
908        // Valid file
909        assert!(
910            validate_file_bounds(
911                1000,   // offset
912                2048,   // decompressed size
913                1024,   // compressed size
914                100000, // archive size
915                &limits,
916            )
917            .is_ok()
918        );
919
920        // File extends beyond archive
921        assert!(
922            validate_file_bounds(
923                99000,  // offset
924                2048,   // decompressed size
925                2000,   // compressed size (would end at 101000)
926                100000, // archive size
927                &limits,
928            )
929            .is_err()
930        );
931
932        // Potential zip bomb
933        assert!(
934            validate_file_bounds(
935                1000,    // offset
936                1000000, // decompressed size (1MB)
937                100,     // compressed size (100 bytes = 10000:1 ratio)
938                100000,  // archive size
939                &limits,
940            )
941            .is_err()
942        );
943    }
944
945    #[test]
946    fn test_compression_ratio_validation() {
947        let limits = SecurityLimits::default();
948
949        // Reasonable compression (10:1)
950        assert!(validate_file_bounds(1000, 10240, 1024, 100000, &limits).is_ok());
951
952        // High but acceptable compression (100:1)
953        assert!(validate_file_bounds(1000, 102400, 1024, 200000, &limits).is_ok());
954
955        // Excessive compression ratio (10000:1 - zip bomb indicator)
956        assert!(validate_file_bounds(1000, 10240000, 1024, 20000000, &limits).is_err());
957    }
958
959    #[test]
960    fn test_sector_validation() {
961        // Valid sector
962        assert!(
963            validate_sector_data(
964                0,    // sector index
965                4096, // sector size
966                2048, // data size
967                None, // no CRC check
968            )
969            .is_ok()
970        );
971
972        // Invalid sector size
973        assert!(validate_sector_data(0, 0, 1024, None).is_err());
974
975        // Data larger than sector
976        assert!(validate_sector_data(0, 1024, 2048, None).is_err());
977
978        // Excessive sector index
979        assert!(validate_sector_data(2_000_000, 4096, 2048, None).is_err());
980    }
981
982    #[test]
983    fn test_session_tracker() {
984        let tracker = SessionTracker::new();
985        let limits = SecurityLimits::default();
986
987        // Initial state
988        let (total, count, _duration) = tracker.get_stats();
989        assert_eq!(total, 0);
990        assert_eq!(count, 0);
991
992        // Record some decompressions
993        tracker.record_decompression(1024);
994        tracker.record_decompression(2048);
995
996        let (total, count, _duration) = tracker.get_stats();
997        assert_eq!(total, 3072);
998        assert_eq!(count, 2);
999
1000        // Session limits should be OK
1001        assert!(tracker.check_session_limits(&limits).is_ok());
1002    }
1003
1004    #[test]
1005    fn test_session_tracker_limit_exceeded() {
1006        let tracker = SessionTracker::new();
1007        let limits = SecurityLimits::strict();
1008
1009        // Exceed session limit
1010        tracker.record_decompression(limits.max_session_decompressed + 1);
1011
1012        // Should fail session limit check
1013        assert!(tracker.check_session_limits(&limits).is_err());
1014    }
1015
1016    #[test]
1017    fn test_decompression_monitor() {
1018        let monitor = DecompressionMonitor::new(
1019            1024 * 1024,            // 1MB limit
1020            Duration::from_secs(5), // 5 second limit
1021        );
1022
1023        // Small decompression should be OK
1024        assert!(monitor.check_progress(1024).is_ok());
1025
1026        // Large decompression should fail
1027        assert!(monitor.check_progress(2 * 1024 * 1024).is_err());
1028
1029        // Test cancellation
1030        monitor.request_cancellation();
1031        assert!(monitor.check_progress(512).is_err());
1032    }
1033
1034    #[test]
1035    fn test_adaptive_compression_limits() {
1036        let adaptive = AdaptiveCompressionLimits::new(1000, true);
1037
1038        // Small files should have higher limits
1039        let small_limit = adaptive.calculate_limit(100, 0x02); // 100 bytes, zlib
1040        let large_limit = adaptive.calculate_limit(100_000, 0x02); // 100KB, zlib
1041
1042        assert!(small_limit > large_limit);
1043        assert!(small_limit >= 1000); // Should be at least base limit
1044
1045        // Different compression methods should have different limits
1046        let zlib_limit = adaptive.calculate_limit(1024, 0x02);
1047        let lzma_limit = adaptive.calculate_limit(1024, 0x12);
1048
1049        assert!(lzma_limit > zlib_limit); // LZMA should allow higher ratios
1050    }
1051
1052    #[test]
1053    fn test_compression_bomb_pattern_detection() {
1054        let limits = SecurityLimits::default();
1055
1056        // Normal compression should pass
1057        assert!(
1058            detect_compression_bomb_patterns(1024, 10240, 0x02, Some("data/file.txt"), &limits)
1059                .is_ok()
1060        );
1061
1062        // Extreme ratio should fail
1063        assert!(
1064            detect_compression_bomb_patterns(
1065                100,
1066                100_000_000,
1067                0x02,
1068                Some("data/file.txt"),
1069                &limits
1070            )
1071            .is_err()
1072        );
1073
1074        // Tiny compressed with huge output should fail
1075        assert!(
1076            detect_compression_bomb_patterns(50, 20_000_000, 0x02, Some("data/file.txt"), &limits)
1077                .is_err()
1078        );
1079
1080        // Nested archive with large size should fail
1081        assert!(
1082            detect_compression_bomb_patterns(
1083                1_000_000,
1084                100_000_000,
1085                0x02,
1086                Some("nested.mpq"),
1087                &limits
1088            )
1089            .is_err()
1090        );
1091    }
1092
1093    #[test]
1094    fn test_decompression_operation_validation() {
1095        let session = SessionTracker::new();
1096        let limits = SecurityLimits::default();
1097
1098        // Valid decompression should succeed
1099        let result = validate_decompression_operation(
1100            1024,
1101            10240,
1102            0x02,
1103            Some("data/file.txt"),
1104            &session,
1105            &limits,
1106        );
1107        assert!(result.is_ok());
1108
1109        // Compression bomb should fail
1110        let result = validate_decompression_operation(
1111            100,
1112            100_000_000,
1113            0x02,
1114            Some("bomb.txt"),
1115            &session,
1116            &limits,
1117        );
1118        assert!(result.is_err());
1119    }
1120
1121    #[test]
1122    fn test_decompression_result_validation() {
1123        // Exact match should pass
1124        assert!(validate_decompression_result(1024, 1024, 5).is_ok());
1125
1126        // Within tolerance should pass
1127        assert!(validate_decompression_result(1024, 1000, 5).is_ok()); // ~2.3% diff
1128        assert!(validate_decompression_result(1024, 1050, 5).is_ok()); // ~2.5% diff
1129
1130        // Outside tolerance should fail
1131        assert!(validate_decompression_result(1024, 900, 5).is_err()); // ~12% diff
1132        assert!(validate_decompression_result(1024, 1150, 5).is_err()); // ~12% diff
1133
1134        // Unknown expected size should always pass
1135        assert!(validate_decompression_result(0, 999999, 5).is_ok());
1136    }
1137
1138    #[test]
1139    fn test_security_limits_extended() {
1140        let default_limits = SecurityLimits::default();
1141        let strict_limits = SecurityLimits::strict();
1142        let permissive_limits = SecurityLimits::permissive();
1143
1144        // Verify session limits are properly set
1145        assert!(strict_limits.max_session_decompressed < default_limits.max_session_decompressed);
1146        assert!(
1147            permissive_limits.max_session_decompressed > default_limits.max_session_decompressed
1148        );
1149
1150        // Verify time limits are properly set
1151        assert!(strict_limits.max_decompression_time < default_limits.max_decompression_time);
1152        assert!(permissive_limits.max_decompression_time > default_limits.max_decompression_time);
1153
1154        // Verify pattern detection is enabled by default
1155        assert!(default_limits.enable_pattern_detection);
1156        assert!(default_limits.enable_adaptive_limits);
1157    }
1158}