oxify_connect_vision/
validation.rs

1//! Input validation module for secure image processing.
2//!
3//! This module provides comprehensive input validation for images including:
4//! - Format verification
5//! - Size limits
6//! - Dimension constraints
7//! - Malicious content detection
8//! - MIME type validation
9
10use crate::errors::{Result, VisionError};
11use image::{DynamicImage, ImageFormat};
12use std::path::Path;
13use tracing::{debug, warn};
14
15/// Maximum allowed image file size (50 MB by default)
16pub const DEFAULT_MAX_FILE_SIZE: usize = 50 * 1024 * 1024;
17
18/// Maximum allowed image dimension (width or height)
19pub const DEFAULT_MAX_DIMENSION: u32 = 10_000;
20
21/// Minimum allowed image dimension (width or height)
22pub const DEFAULT_MIN_DIMENSION: u32 = 10;
23
24/// Configuration for image validation
25#[derive(Debug, Clone)]
26pub struct ValidationConfig {
27    /// Maximum allowed file size in bytes
28    pub max_file_size: usize,
29
30    /// Maximum allowed width or height
31    pub max_dimension: u32,
32
33    /// Minimum allowed width or height
34    pub min_dimension: u32,
35
36    /// Allowed image formats
37    pub allowed_formats: Vec<ImageFormat>,
38
39    /// Whether to perform deep content inspection
40    pub deep_inspection: bool,
41
42    /// Whether to validate aspect ratio
43    pub validate_aspect_ratio: bool,
44
45    /// Maximum aspect ratio (width/height)
46    pub max_aspect_ratio: f32,
47}
48
49impl Default for ValidationConfig {
50    fn default() -> Self {
51        Self {
52            max_file_size: DEFAULT_MAX_FILE_SIZE,
53            max_dimension: DEFAULT_MAX_DIMENSION,
54            min_dimension: DEFAULT_MIN_DIMENSION,
55            allowed_formats: vec![
56                ImageFormat::Png,
57                ImageFormat::Jpeg,
58                ImageFormat::WebP,
59                ImageFormat::Tiff,
60                ImageFormat::Bmp,
61            ],
62            deep_inspection: true,
63            validate_aspect_ratio: true,
64            max_aspect_ratio: 100.0, // Allow up to 100:1 aspect ratios
65        }
66    }
67}
68
69impl ValidationConfig {
70    /// Create a permissive configuration for testing
71    pub fn permissive() -> Self {
72        Self {
73            max_file_size: usize::MAX,
74            max_dimension: u32::MAX,
75            min_dimension: 1,
76            allowed_formats: ImageFormat::all().collect(),
77            deep_inspection: false,
78            validate_aspect_ratio: false,
79            max_aspect_ratio: f32::MAX,
80        }
81    }
82
83    /// Create a strict configuration for production
84    pub fn strict() -> Self {
85        Self {
86            max_file_size: 10 * 1024 * 1024, // 10 MB
87            max_dimension: 5_000,
88            min_dimension: 50,
89            allowed_formats: vec![ImageFormat::Png, ImageFormat::Jpeg],
90            deep_inspection: true,
91            validate_aspect_ratio: true,
92            max_aspect_ratio: 20.0,
93        }
94    }
95}
96
97/// Image validator for security checks
98pub struct ImageValidator {
99    config: ValidationConfig,
100}
101
102impl ImageValidator {
103    /// Create a new validator with default configuration
104    pub fn new() -> Self {
105        Self {
106            config: ValidationConfig::default(),
107        }
108    }
109
110    /// Create a new validator with custom configuration
111    pub fn with_config(config: ValidationConfig) -> Self {
112        Self { config }
113    }
114
115    /// Validate image bytes before processing
116    pub fn validate_bytes(&self, data: &[u8]) -> Result<()> {
117        debug!("Validating image bytes (size: {} bytes)", data.len());
118
119        // Check file size
120        if data.len() > self.config.max_file_size {
121            warn!(
122                "Image size {} exceeds maximum allowed size {}",
123                data.len(),
124                self.config.max_file_size
125            );
126            return Err(VisionError::InvalidFormat(format!(
127                "Image size {} bytes exceeds maximum allowed {} bytes",
128                data.len(),
129                self.config.max_file_size
130            )));
131        }
132
133        // Check if data is empty
134        if data.is_empty() {
135            return Err(VisionError::InvalidFormat(
136                "Image data is empty".to_string(),
137            ));
138        }
139
140        // Detect and validate format
141        let format = self.detect_format(data)?;
142        self.validate_format(&format)?;
143
144        // Load image for dimension validation
145        let image = image::load_from_memory(data)
146            .map_err(|e| VisionError::ImageDecode(format!("Failed to decode image: {}", e)))?;
147
148        // Validate dimensions
149        self.validate_dimensions(&image)?;
150
151        // Perform deep inspection if enabled
152        if self.config.deep_inspection {
153            self.deep_inspect(&image)?;
154        }
155
156        debug!("Image validation passed");
157        Ok(())
158    }
159
160    /// Validate image file before processing
161    pub fn validate_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
162        let path = path.as_ref();
163        debug!("Validating image file: {}", path.display());
164
165        // Check if file exists
166        if !path.exists() {
167            return Err(VisionError::InvalidFormat(format!(
168                "File not found: {}",
169                path.display()
170            )));
171        }
172
173        // Check file size
174        let metadata = std::fs::metadata(path).map_err(|e| {
175            VisionError::InvalidFormat(format!("Failed to read file metadata: {}", e))
176        })?;
177
178        if metadata.len() > self.config.max_file_size as u64 {
179            return Err(VisionError::InvalidFormat(format!(
180                "File size {} bytes exceeds maximum allowed {} bytes",
181                metadata.len(),
182                self.config.max_file_size
183            )));
184        }
185
186        // Validate file extension
187        if let Some(ext) = path.extension() {
188            let ext_str = ext.to_string_lossy().to_lowercase();
189            let valid_extensions = ["png", "jpg", "jpeg", "webp", "tiff", "tif", "bmp"];
190            if !valid_extensions.contains(&ext_str.as_str()) {
191                warn!("Suspicious file extension: {}", ext_str);
192            }
193        }
194
195        // Read and validate file content
196        let data = std::fs::read(path)
197            .map_err(|e| VisionError::InvalidFormat(format!("Failed to read file: {}", e)))?;
198
199        self.validate_bytes(&data)
200    }
201
202    /// Validate a DynamicImage
203    pub fn validate_image(&self, image: &DynamicImage) -> Result<()> {
204        debug!("Validating DynamicImage");
205        self.validate_dimensions(image)?;
206
207        if self.config.deep_inspection {
208            self.deep_inspect(image)?;
209        }
210
211        Ok(())
212    }
213
214    /// Detect image format from bytes
215    fn detect_format(&self, data: &[u8]) -> Result<ImageFormat> {
216        image::guess_format(data).map_err(|e| {
217            VisionError::InvalidFormat(format!("Failed to detect image format: {}", e))
218        })
219    }
220
221    /// Validate that the image format is allowed
222    fn validate_format(&self, format: &ImageFormat) -> Result<()> {
223        if !self.config.allowed_formats.contains(format) {
224            return Err(VisionError::InvalidFormat(format!(
225                "Image format {:?} is not allowed. Allowed formats: {:?}",
226                format, self.config.allowed_formats
227            )));
228        }
229        Ok(())
230    }
231
232    /// Validate image dimensions
233    fn validate_dimensions(&self, image: &DynamicImage) -> Result<()> {
234        let (width, height) = (image.width(), image.height());
235
236        debug!("Image dimensions: {}x{}", width, height);
237
238        // Check maximum dimensions
239        if width > self.config.max_dimension || height > self.config.max_dimension {
240            return Err(VisionError::InvalidFormat(format!(
241                "Image dimensions {}x{} exceed maximum allowed {}x{}",
242                width, height, self.config.max_dimension, self.config.max_dimension
243            )));
244        }
245
246        // Check minimum dimensions
247        if width < self.config.min_dimension || height < self.config.min_dimension {
248            return Err(VisionError::InvalidFormat(format!(
249                "Image dimensions {}x{} are below minimum required {}x{}",
250                width, height, self.config.min_dimension, self.config.min_dimension
251            )));
252        }
253
254        // Validate aspect ratio
255        if self.config.validate_aspect_ratio {
256            let aspect_ratio = width.max(height) as f32 / width.min(height) as f32;
257            if aspect_ratio > self.config.max_aspect_ratio {
258                warn!(
259                    "Image aspect ratio {:.2} exceeds maximum {:.2}",
260                    aspect_ratio, self.config.max_aspect_ratio
261                );
262                return Err(VisionError::InvalidFormat(format!(
263                    "Image aspect ratio {:.2} exceeds maximum allowed {:.2}",
264                    aspect_ratio, self.config.max_aspect_ratio
265                )));
266            }
267        }
268
269        Ok(())
270    }
271
272    /// Perform deep content inspection
273    fn deep_inspect(&self, image: &DynamicImage) -> Result<()> {
274        debug!("Performing deep content inspection");
275
276        // Check for suspicious patterns
277        self.check_entropy(image)?;
278        self.check_color_distribution(image)?;
279
280        Ok(())
281    }
282
283    /// Check image entropy (randomness) to detect potential attacks
284    fn check_entropy(&self, image: &DynamicImage) -> Result<()> {
285        let pixels = image.to_rgba8();
286        let (width, height) = pixels.dimensions();
287        let pixel_count = (width * height) as usize;
288
289        // Very basic entropy check: count unique colors
290        // A completely random image might indicate an attack
291        let mut color_samples = std::collections::HashSet::new();
292        let sample_size = pixel_count.min(1000);
293
294        for y in
295            (0..height).step_by((height as usize / (sample_size as f32).sqrt() as usize).max(1))
296        {
297            for x in
298                (0..width).step_by((width as usize / (sample_size as f32).sqrt() as usize).max(1))
299            {
300                let pixel = pixels.get_pixel(x, y);
301                let color = (pixel[0], pixel[1], pixel[2], pixel[3]);
302                color_samples.insert(color);
303            }
304        }
305
306        let uniqueness_ratio = color_samples.len() as f32 / sample_size as f32;
307
308        // If almost every sampled pixel is unique, might be suspicious
309        if uniqueness_ratio > 0.95 {
310            debug!(
311                "High entropy detected: uniqueness ratio = {:.2}",
312                uniqueness_ratio
313            );
314            // Note: This is just a warning, not a hard error
315            // Many legitimate images can have high entropy
316        }
317
318        Ok(())
319    }
320
321    /// Check color distribution for anomalies
322    fn check_color_distribution(&self, image: &DynamicImage) -> Result<()> {
323        let pixels = image.to_rgba8();
324
325        // Check if image is completely transparent
326        let mut all_transparent = true;
327        for chunk in pixels.chunks(4) {
328            if chunk[3] > 0 {
329                // Alpha channel
330                all_transparent = false;
331                break;
332            }
333        }
334
335        if all_transparent {
336            return Err(VisionError::InvalidFormat(
337                "Image is completely transparent".to_string(),
338            ));
339        }
340
341        Ok(())
342    }
343
344    /// Get the current configuration
345    pub fn config(&self) -> &ValidationConfig {
346        &self.config
347    }
348}
349
350impl Default for ImageValidator {
351    fn default() -> Self {
352        Self::new()
353    }
354}
355
356/// Validate image bytes with default configuration
357pub fn validate_image_bytes(data: &[u8]) -> Result<()> {
358    ImageValidator::new().validate_bytes(data)
359}
360
361/// Validate image file with default configuration
362pub fn validate_image_file<P: AsRef<Path>>(path: P) -> Result<()> {
363    ImageValidator::new().validate_file(path)
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use image::{ImageBuffer, Rgba};
370    use std::io::Cursor;
371
372    fn create_test_image(width: u32, height: u32) -> DynamicImage {
373        let img = ImageBuffer::from_fn(width, height, |x, y| {
374            Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255])
375        });
376        DynamicImage::ImageRgba8(img)
377    }
378
379    fn image_to_bytes(image: &DynamicImage, format: ImageFormat) -> Vec<u8> {
380        let mut bytes = Vec::new();
381        let mut cursor = Cursor::new(&mut bytes);
382        image.write_to(&mut cursor, format).unwrap();
383        bytes
384    }
385
386    #[test]
387    fn test_validator_creation() {
388        let validator = ImageValidator::new();
389        assert_eq!(validator.config.max_file_size, DEFAULT_MAX_FILE_SIZE);
390    }
391
392    #[test]
393    fn test_validator_with_config() {
394        let config = ValidationConfig::strict();
395        let validator = ImageValidator::with_config(config.clone());
396        assert_eq!(validator.config.max_file_size, config.max_file_size);
397    }
398
399    #[test]
400    fn test_validate_dimensions_valid() {
401        let validator = ImageValidator::new();
402        let image = create_test_image(800, 600);
403        assert!(validator.validate_dimensions(&image).is_ok());
404    }
405
406    #[test]
407    fn test_validate_dimensions_too_large() {
408        let validator = ImageValidator::new();
409        let image = create_test_image(15000, 600);
410        assert!(validator.validate_dimensions(&image).is_err());
411    }
412
413    #[test]
414    fn test_validate_dimensions_too_small() {
415        let validator = ImageValidator::new();
416        let image = create_test_image(5, 5);
417        assert!(validator.validate_dimensions(&image).is_err());
418    }
419
420    #[test]
421    fn test_validate_aspect_ratio_valid() {
422        let config = ValidationConfig {
423            max_aspect_ratio: 10.0,
424            ..Default::default()
425        };
426        let validator = ImageValidator::with_config(config);
427        let image = create_test_image(1000, 100); // 10:1 aspect ratio
428        assert!(validator.validate_dimensions(&image).is_ok());
429    }
430
431    #[test]
432    fn test_validate_aspect_ratio_invalid() {
433        let config = ValidationConfig {
434            max_aspect_ratio: 5.0,
435            ..Default::default()
436        };
437        let validator = ImageValidator::with_config(config);
438        let image = create_test_image(1000, 100); // 10:1 aspect ratio
439        assert!(validator.validate_dimensions(&image).is_err());
440    }
441
442    #[test]
443    fn test_validate_bytes_valid_png() {
444        let image = create_test_image(800, 600);
445        let bytes = image_to_bytes(&image, ImageFormat::Png);
446        let validator = ImageValidator::new();
447        assert!(validator.validate_bytes(&bytes).is_ok());
448    }
449
450    #[test]
451    fn test_validate_bytes_empty() {
452        let validator = ImageValidator::new();
453        let result = validator.validate_bytes(&[]);
454        assert!(result.is_err());
455    }
456
457    #[test]
458    fn test_validate_bytes_too_large() {
459        let config = ValidationConfig {
460            max_file_size: 1000,
461            ..Default::default()
462        };
463        let validator = ImageValidator::with_config(config);
464        let image = create_test_image(800, 600);
465        let bytes = image_to_bytes(&image, ImageFormat::Png);
466        assert!(validator.validate_bytes(&bytes).is_err());
467    }
468
469    #[test]
470    fn test_validate_format_allowed() {
471        let validator = ImageValidator::new();
472        assert!(validator.validate_format(&ImageFormat::Png).is_ok());
473        assert!(validator.validate_format(&ImageFormat::Jpeg).is_ok());
474    }
475
476    #[test]
477    fn test_validate_format_not_allowed() {
478        let config = ValidationConfig {
479            allowed_formats: vec![ImageFormat::Png],
480            ..Default::default()
481        };
482        let validator = ImageValidator::with_config(config);
483        assert!(validator.validate_format(&ImageFormat::Jpeg).is_err());
484    }
485
486    #[test]
487    fn test_permissive_config() {
488        let config = ValidationConfig::permissive();
489        assert_eq!(config.max_file_size, usize::MAX);
490        assert_eq!(config.max_dimension, u32::MAX);
491    }
492
493    #[test]
494    fn test_strict_config() {
495        let config = ValidationConfig::strict();
496        assert_eq!(config.max_file_size, 10 * 1024 * 1024);
497        assert_eq!(config.max_dimension, 5_000);
498    }
499
500    #[test]
501    fn test_check_entropy() {
502        let validator = ImageValidator::new();
503        let image = create_test_image(800, 600);
504        assert!(validator.check_entropy(&image).is_ok());
505    }
506
507    #[test]
508    fn test_check_color_distribution_valid() {
509        let validator = ImageValidator::new();
510        let image = create_test_image(800, 600);
511        assert!(validator.check_color_distribution(&image).is_ok());
512    }
513
514    #[test]
515    fn test_check_color_distribution_transparent() {
516        let validator = ImageValidator::new();
517        let img = ImageBuffer::from_pixel(100, 100, Rgba([0, 0, 0, 0]));
518        let image = DynamicImage::ImageRgba8(img);
519        assert!(validator.check_color_distribution(&image).is_err());
520    }
521
522    #[test]
523    fn test_validate_image_bytes_helper() {
524        let image = create_test_image(800, 600);
525        let bytes = image_to_bytes(&image, ImageFormat::Png);
526        assert!(validate_image_bytes(&bytes).is_ok());
527    }
528
529    #[test]
530    fn test_deep_inspection() {
531        let validator = ImageValidator::new();
532        let image = create_test_image(800, 600);
533        assert!(validator.deep_inspect(&image).is_ok());
534    }
535
536    #[test]
537    fn test_validation_config_default() {
538        let config = ValidationConfig::default();
539        assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE);
540        assert_eq!(config.max_dimension, DEFAULT_MAX_DIMENSION);
541        assert!(config.deep_inspection);
542    }
543}