ricecoder_images/
handler.rs

1//! Image drag-and-drop event handling.
2
3use crate::config::ImageConfig;
4use crate::error::{ImageError, ImageResult};
5use crate::formats::ImageFormat;
6use crate::models::ImageMetadata;
7use sha2::{Digest, Sha256};
8use std::path::{Path, PathBuf};
9
10/// Handles image drag-and-drop events and file operations.
11pub struct ImageHandler {
12    config: ImageConfig,
13}
14
15impl ImageHandler {
16    /// Create a new image handler with the given configuration.
17    pub fn new(config: ImageConfig) -> Self {
18        Self { config }
19    }
20
21    /// Create a new image handler with default configuration.
22    pub fn with_default_config() -> ImageResult<Self> {
23        let config = ImageConfig::load_with_hierarchy()?;
24        Ok(Self::new(config))
25    }
26
27    /// Sanitize a file path to prevent directory traversal attacks.
28    ///
29    /// # Arguments
30    ///
31    /// * `path` - The path to sanitize
32    ///
33    /// # Returns
34    ///
35    /// The sanitized path, or an error if path traversal is detected
36    fn sanitize_path(path: &Path) -> ImageResult<PathBuf> {
37        // Normalize the path to resolve .. and . components
38        let normalized = path.canonicalize().map_err(|_| {
39            ImageError::PathTraversalError
40        })?;
41
42        // Ensure the path is absolute and doesn't contain suspicious patterns
43        if !normalized.is_absolute() {
44            return Err(ImageError::PathTraversalError);
45        }
46
47        Ok(normalized)
48    }
49
50    /// Read an image file and validate it.
51    ///
52    /// # Arguments
53    ///
54    /// * `path` - Path to the image file
55    ///
56    /// # Returns
57    ///
58    /// Image metadata if successful, error otherwise
59    ///
60    /// # Requirements
61    ///
62    /// - Req 1.2: Read file using ricecoder-files integration
63    /// - Req 1.3: Validate format (PNG, JPG, GIF, WebP)
64    /// - Req 1.5: Report error with format details
65    /// - Security: Sanitize file paths to prevent directory traversal
66    pub fn read_image(&self, path: &Path) -> ImageResult<ImageMetadata> {
67        // Sanitize path to prevent directory traversal
68        let sanitized_path = Self::sanitize_path(path)?;
69
70        // Validate file exists and is readable
71        if !sanitized_path.exists() {
72            return Err(ImageError::InvalidFile(
73                format!("File does not exist: {}", sanitized_path.display()),
74            ));
75        }
76
77        // Check if it's a file (not a directory)
78        if !sanitized_path.is_file() {
79            return Err(ImageError::InvalidFile(
80                "Path is not a file".to_string(),
81            ));
82        }
83
84        // Validate format and size
85        let format = ImageFormat::validate_file(
86            &sanitized_path,
87            self.config.analysis.max_image_size_mb,
88        )?;
89
90        // Extract metadata (width, height)
91        let (width, height) = ImageFormat::extract_metadata(&sanitized_path)?;
92
93        // Calculate SHA256 hash
94        let hash = self.calculate_file_hash(&sanitized_path)?;
95
96        // Get file size
97        let metadata = std::fs::metadata(&sanitized_path)?;
98        let size_bytes = metadata.len();
99
100        Ok(ImageMetadata::new(
101            sanitized_path,
102            format,
103            size_bytes,
104            width,
105            height,
106            hash,
107        ))
108    }
109
110    /// Calculate SHA256 hash of a file.
111    ///
112    /// # Arguments
113    ///
114    /// * `path` - Path to the file
115    ///
116    /// # Returns
117    ///
118    /// Hex-encoded SHA256 hash
119    fn calculate_file_hash(&self, path: &Path) -> ImageResult<String> {
120        let mut file = std::fs::File::open(path)?;
121        let mut hasher = Sha256::new();
122        std::io::copy(&mut file, &mut hasher)?;
123        let hash = hasher.finalize();
124        Ok(format!("{:x}", hash))
125    }
126
127    /// Validate image format is supported.
128    ///
129    /// # Arguments
130    ///
131    /// * `format` - The image format to validate
132    ///
133    /// # Returns
134    ///
135    /// Ok if format is supported, error with supported formats list otherwise
136    ///
137    /// # Requirements
138    ///
139    /// - Req 1.3: Validate format (PNG, JPG, GIF, WebP)
140    /// - Req 4.5: Report error with supported formats list
141    pub fn validate_format(&self, format: ImageFormat) -> ImageResult<()> {
142        let format_str = format.as_str();
143        if self.config.is_format_supported(format_str) {
144            Ok(())
145        } else {
146            Err(ImageError::FormatNotSupported(
147                format!(
148                    "Format '{}' is not supported. Supported formats: {}",
149                    format_str,
150                    self.config.supported_formats_string()
151                ),
152            ))
153        }
154    }
155
156    /// Handle multiple files from a drag-and-drop event.
157    ///
158    /// # Arguments
159    ///
160    /// * `paths` - Paths to the dropped files
161    ///
162    /// # Returns
163    ///
164    /// Vector of successfully read images and any errors encountered
165    ///
166    /// # Requirements
167    ///
168    /// - Req 1.1: Handle multiple files in single drag-and-drop
169    pub fn handle_dropped_files(
170        &self,
171        paths: &[PathBuf],
172    ) -> (Vec<ImageMetadata>, Vec<ImageError>) {
173        let mut images = Vec::new();
174        let mut errors = Vec::new();
175
176        for path in paths {
177            match self.read_image(path) {
178                Ok(metadata) => images.push(metadata),
179                Err(e) => errors.push(e),
180            }
181        }
182
183        (images, errors)
184    }
185
186    /// Get the configuration.
187    pub fn config(&self) -> &ImageConfig {
188        &self.config
189    }
190
191    /// Extract file paths from a drag-and-drop event.
192    ///
193    /// # Arguments
194    ///
195    /// * `event_data` - Raw event data from the terminal (typically file paths)
196    ///
197    /// # Returns
198    ///
199    /// Vector of file paths extracted from the event
200    ///
201    /// # Requirements
202    ///
203    /// - Req 1.1: Create interface for receiving drag-and-drop events from ricecoder-tui
204    /// - Req 1.1: Implement file path extraction from events
205    pub fn extract_paths_from_event(event_data: &str) -> Vec<PathBuf> {
206        // First try to split by newlines (most common for drag-and-drop)
207        let lines: Vec<&str> = event_data.lines().collect();
208        
209        if lines.len() > 1 || (lines.len() == 1 && !lines[0].contains(' ')) {
210            // Multiple lines or single line without spaces - use line splitting
211            return lines
212                .into_iter()
213                .filter(|line| !line.is_empty())
214                .map(PathBuf::from)
215                .collect();
216        }
217        
218        // Single line with spaces - split by whitespace
219        event_data
220            .split_whitespace()
221            .filter(|s| !s.is_empty())
222            .map(PathBuf::from)
223            .collect()
224    }
225
226    /// Check if a file exists and is readable.
227    ///
228    /// # Arguments
229    ///
230    /// * `path` - Path to check
231    ///
232    /// # Returns
233    ///
234    /// Ok if file exists and is readable, error otherwise
235    ///
236    /// # Requirements
237    ///
238    /// - Req 1.1: Implement file existence and permission checks
239    pub fn check_file_accessible(path: &Path) -> ImageResult<()> {
240        // Check if file exists
241        if !path.exists() {
242            return Err(ImageError::InvalidFile(
243                format!("File does not exist: {}", path.display()),
244            ));
245        }
246
247        // Check if it's a file (not a directory)
248        if !path.is_file() {
249            return Err(ImageError::InvalidFile(
250                "Path is not a file".to_string(),
251            ));
252        }
253
254        // Try to open the file to check permissions
255        std::fs::File::open(path).map_err(|e| {
256            ImageError::InvalidFile(
257                format!("Cannot read file: {}", e),
258            )
259        })?;
260
261        Ok(())
262    }
263
264    /// Process a drag-and-drop event with multiple files.
265    ///
266    /// # Arguments
267    ///
268    /// * `event_data` - Raw event data from the terminal
269    ///
270    /// # Returns
271    ///
272    /// Tuple of (successfully processed images, errors encountered)
273    ///
274    /// # Requirements
275    ///
276    /// - Req 1.1: Handle multiple files in single drag-and-drop
277    /// - Req 1.1: Implement file existence and permission checks
278    pub fn process_drag_drop_event(
279        &self,
280        event_data: &str,
281    ) -> (Vec<ImageMetadata>, Vec<ImageError>) {
282        let paths = Self::extract_paths_from_event(event_data);
283        
284        let mut images = Vec::new();
285        let mut errors = Vec::new();
286
287        for path in paths {
288            // Check file accessibility first
289            if let Err(e) = Self::check_file_accessible(&path) {
290                errors.push(e);
291                continue;
292            }
293
294            // Try to read the image
295            match self.read_image(&path) {
296                Ok(metadata) => images.push(metadata),
297                Err(e) => errors.push(e),
298            }
299        }
300
301        (images, errors)
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use std::io::Write;
309    use tempfile::NamedTempFile;
310
311    #[test]
312    fn test_handler_creation() {
313        let config = ImageConfig::default();
314        let handler = ImageHandler::new(config);
315        assert!(handler.config.cache.enabled);
316    }
317
318    #[test]
319    fn test_handler_default_creation() {
320        let handler = ImageHandler::with_default_config();
321        assert!(handler.is_ok());
322    }
323
324    #[test]
325    fn test_sanitize_path_valid() {
326        let temp_dir = tempfile::tempdir().unwrap();
327        let temp_path = temp_dir.path().to_path_buf();
328        
329        let result = ImageHandler::sanitize_path(&temp_path);
330        assert!(result.is_ok());
331    }
332
333    #[test]
334    fn test_sanitize_path_nonexistent() {
335        let path = PathBuf::from("/nonexistent/path/that/does/not/exist");
336        let result = ImageHandler::sanitize_path(&path);
337        assert!(result.is_err());
338    }
339
340    #[test]
341    fn test_read_image_nonexistent_file() {
342        let config = ImageConfig::default();
343        let handler = ImageHandler::new(config);
344        
345        let result = handler.read_image(Path::new("/nonexistent/image.png"));
346        assert!(result.is_err());
347    }
348
349    #[test]
350    fn test_read_image_valid_png() {
351        let config = ImageConfig::default();
352        let handler = ImageHandler::new(config);
353
354        // Create a temporary PNG file with valid magic bytes
355        let mut temp_file = NamedTempFile::new().unwrap();
356        let png_header = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
357        temp_file.write_all(&png_header).unwrap();
358        temp_file.flush().unwrap();
359
360        // This will fail because it's not a valid PNG, but we can test the path handling
361        let result = handler.read_image(temp_file.path());
362        // We expect an error because the file is not a valid image
363        assert!(result.is_err());
364    }
365
366    #[test]
367    fn test_validate_format_supported() {
368        let config = ImageConfig::default();
369        let handler = ImageHandler::new(config);
370
371        let result = handler.validate_format(ImageFormat::Png);
372        assert!(result.is_ok());
373    }
374
375    #[test]
376    fn test_validate_format_all_supported() {
377        let config = ImageConfig::default();
378        let handler = ImageHandler::new(config);
379
380        assert!(handler.validate_format(ImageFormat::Png).is_ok());
381        assert!(handler.validate_format(ImageFormat::Jpeg).is_ok());
382        assert!(handler.validate_format(ImageFormat::Gif).is_ok());
383        assert!(handler.validate_format(ImageFormat::WebP).is_ok());
384    }
385
386    #[test]
387    fn test_handle_dropped_files_empty() {
388        let config = ImageConfig::default();
389        let handler = ImageHandler::new(config);
390
391        let (images, errors) = handler.handle_dropped_files(&[]);
392        assert_eq!(images.len(), 0);
393        assert_eq!(errors.len(), 0);
394    }
395
396    #[test]
397    fn test_handle_dropped_files_nonexistent() {
398        let config = ImageConfig::default();
399        let handler = ImageHandler::new(config);
400
401        let paths = vec![
402            PathBuf::from("/nonexistent/image1.png"),
403            PathBuf::from("/nonexistent/image2.jpg"),
404        ];
405
406        let (images, errors) = handler.handle_dropped_files(&paths);
407        assert_eq!(images.len(), 0);
408        assert_eq!(errors.len(), 2);
409    }
410
411    #[test]
412    fn test_calculate_file_hash() {
413        let config = ImageConfig::default();
414        let handler = ImageHandler::new(config);
415
416        // Create a temporary file with known content
417        let mut temp_file = NamedTempFile::new().unwrap();
418        temp_file.write_all(b"test content").unwrap();
419        temp_file.flush().unwrap();
420
421        let hash = handler.calculate_file_hash(temp_file.path());
422        assert!(hash.is_ok());
423        
424        let hash_str = hash.unwrap();
425        // SHA256 of "test content" should be a 64-character hex string
426        assert_eq!(hash_str.len(), 64);
427    }
428
429    #[test]
430    fn test_calculate_file_hash_consistency() {
431        let config = ImageConfig::default();
432        let handler = ImageHandler::new(config);
433
434        // Create a temporary file with known content
435        let mut temp_file = NamedTempFile::new().unwrap();
436        temp_file.write_all(b"test content").unwrap();
437        temp_file.flush().unwrap();
438
439        let hash1 = handler.calculate_file_hash(temp_file.path()).unwrap();
440        let hash2 = handler.calculate_file_hash(temp_file.path()).unwrap();
441
442        // Same file should produce same hash
443        assert_eq!(hash1, hash2);
444    }
445
446    #[test]
447    fn test_extract_paths_from_event_single_line() {
448        let event_data = "/path/to/image.png";
449        let paths = ImageHandler::extract_paths_from_event(event_data);
450        
451        assert_eq!(paths.len(), 1);
452        assert_eq!(paths[0], PathBuf::from("/path/to/image.png"));
453    }
454
455    #[test]
456    fn test_extract_paths_from_event_multiple_lines() {
457        let event_data = "/path/to/image1.png\n/path/to/image2.jpg\n/path/to/image3.gif";
458        let paths = ImageHandler::extract_paths_from_event(event_data);
459        
460        assert_eq!(paths.len(), 3);
461        assert_eq!(paths[0], PathBuf::from("/path/to/image1.png"));
462        assert_eq!(paths[1], PathBuf::from("/path/to/image2.jpg"));
463        assert_eq!(paths[2], PathBuf::from("/path/to/image3.gif"));
464    }
465
466    #[test]
467    fn test_extract_paths_from_event_space_separated() {
468        let event_data = "/path/to/image1.png /path/to/image2.jpg";
469        let paths = ImageHandler::extract_paths_from_event(event_data);
470        
471        assert_eq!(paths.len(), 2);
472        assert_eq!(paths[0], PathBuf::from("/path/to/image1.png"));
473        assert_eq!(paths[1], PathBuf::from("/path/to/image2.jpg"));
474    }
475
476    #[test]
477    fn test_extract_paths_from_event_empty() {
478        let event_data = "";
479        let paths = ImageHandler::extract_paths_from_event(event_data);
480        
481        assert_eq!(paths.len(), 0);
482    }
483
484    #[test]
485    fn test_check_file_accessible_nonexistent() {
486        let result = ImageHandler::check_file_accessible(Path::new("/nonexistent/file.png"));
487        assert!(result.is_err());
488    }
489
490    #[test]
491    fn test_check_file_accessible_valid() {
492        let temp_file = NamedTempFile::new().unwrap();
493        let result = ImageHandler::check_file_accessible(temp_file.path());
494        assert!(result.is_ok());
495    }
496
497    #[test]
498    fn test_check_file_accessible_directory() {
499        let temp_dir = tempfile::tempdir().unwrap();
500        let result = ImageHandler::check_file_accessible(temp_dir.path());
501        assert!(result.is_err());
502    }
503
504    #[test]
505    fn test_process_drag_drop_event_empty() {
506        let config = ImageConfig::default();
507        let handler = ImageHandler::new(config);
508
509        let (images, errors) = handler.process_drag_drop_event("");
510        assert_eq!(images.len(), 0);
511        assert_eq!(errors.len(), 0);
512    }
513
514    #[test]
515    fn test_process_drag_drop_event_nonexistent_files() {
516        let config = ImageConfig::default();
517        let handler = ImageHandler::new(config);
518
519        let event_data = "/nonexistent/image1.png\n/nonexistent/image2.jpg";
520        let (images, errors) = handler.process_drag_drop_event(event_data);
521        
522        assert_eq!(images.len(), 0);
523        assert_eq!(errors.len(), 2);
524    }
525
526    #[test]
527    fn test_process_drag_drop_event_mixed_valid_invalid() {
528        let config = ImageConfig::default();
529        let handler = ImageHandler::new(config);
530
531        let temp_file = NamedTempFile::new().unwrap();
532        let temp_path = temp_file.path().to_string_lossy().to_string();
533
534        let event_data = format!("{}\n/nonexistent/image.png", temp_path);
535        let (_images, errors) = handler.process_drag_drop_event(&event_data);
536        
537        // One file exists but is not a valid image, one doesn't exist
538        assert_eq!(errors.len(), 2);
539    }
540
541    #[test]
542    fn test_error_message_format_not_supported() {
543        let config = ImageConfig::default();
544        let _handler = ImageHandler::new(config);
545
546        // Create a custom config with limited formats
547        let mut limited_config = ImageConfig::default();
548        limited_config.formats.supported = vec!["png".to_string()];
549        let limited_handler = ImageHandler::new(limited_config);
550
551        let result = limited_handler.validate_format(ImageFormat::Jpeg);
552        assert!(result.is_err());
553        
554        let error_msg = result.unwrap_err().to_string();
555        assert!(error_msg.contains("not supported"));
556        assert!(error_msg.contains("png"));
557    }
558
559    #[test]
560    fn test_error_message_file_too_large() {
561        let config = ImageConfig::default();
562        let handler = ImageHandler::new(config);
563
564        // Create a temporary file larger than max size
565        let mut temp_file = NamedTempFile::new().unwrap();
566        let large_data = vec![0u8; 11 * 1024 * 1024]; // 11 MB
567        temp_file.write_all(&large_data).unwrap();
568        temp_file.flush().unwrap();
569
570        let result = handler.read_image(temp_file.path());
571        assert!(result.is_err());
572        
573        let error_msg = result.unwrap_err().to_string();
574        assert!(error_msg.contains("too large") || error_msg.contains("exceeds"));
575    }
576
577    #[test]
578    fn test_error_message_invalid_file() {
579        let config = ImageConfig::default();
580        let handler = ImageHandler::new(config);
581
582        // Create a temporary file with invalid image data
583        let mut temp_file = NamedTempFile::new().unwrap();
584        temp_file.write_all(b"not an image").unwrap();
585        temp_file.flush().unwrap();
586
587        let result = handler.read_image(temp_file.path());
588        assert!(result.is_err());
589        
590        let error_msg = result.unwrap_err().to_string();
591        assert!(error_msg.contains("Invalid") || error_msg.contains("invalid"));
592    }
593
594    #[test]
595    fn test_path_sanitization_prevents_traversal() {
596        // Test that path traversal attempts are blocked
597        let suspicious_path = PathBuf::from("../../../etc/passwd");
598        let result = ImageHandler::sanitize_path(&suspicious_path);
599        
600        // Should fail because the path doesn't exist
601        assert!(result.is_err());
602    }
603
604    #[test]
605    fn test_error_message_path_traversal() {
606        let config = ImageConfig::default();
607        let handler = ImageHandler::new(config);
608
609        // Try to read a file with path traversal
610        let suspicious_path = Path::new("../../../etc/passwd");
611        let result = handler.read_image(suspicious_path);
612        
613        assert!(result.is_err());
614        let error_msg = result.unwrap_err().to_string();
615        // Should contain either path traversal error or file not found
616        assert!(error_msg.contains("traversal") || error_msg.contains("does not exist"));
617    }
618
619    #[test]
620    fn test_format_validation_error_includes_supported_formats() {
621        let config = ImageConfig::default();
622        let handler = ImageHandler::new(config);
623
624        // Validate that error messages include the list of supported formats
625        let result = handler.validate_format(ImageFormat::Png);
626        assert!(result.is_ok());
627
628        // Check that the config can provide supported formats string
629        let formats_str = handler.config().supported_formats_string();
630        assert!(formats_str.contains("png"));
631        assert!(formats_str.contains("jpg"));
632        assert!(formats_str.contains("gif"));
633        assert!(formats_str.contains("webp"));
634    }
635
636    #[test]
637    fn test_read_image_with_valid_metadata() {
638        let config = ImageConfig::default();
639        let handler = ImageHandler::new(config);
640
641        // Create a temporary PNG file with valid header
642        let mut temp_file = NamedTempFile::new().unwrap();
643        let png_header = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
644        temp_file.write_all(&png_header).unwrap();
645        temp_file.flush().unwrap();
646
647        // This will fail because it's not a complete valid PNG, but we can verify
648        // that the error is about the image format, not the file access
649        let result = handler.read_image(temp_file.path());
650        
651        // The error should be about invalid image, not file access
652        if let Err(e) = result {
653            let error_msg = e.to_string();
654            assert!(error_msg.contains("Invalid") || error_msg.contains("invalid"));
655        }
656    }
657
658    #[test]
659    fn test_multiple_files_error_collection() {
660        let config = ImageConfig::default();
661        let handler = ImageHandler::new(config);
662
663        let paths = vec![
664            PathBuf::from("/nonexistent/image1.png"),
665            PathBuf::from("/nonexistent/image2.jpg"),
666            PathBuf::from("/nonexistent/image3.gif"),
667        ];
668
669        let (_images, errors) = handler.handle_dropped_files(&paths);
670        
671        // All files should fail
672        assert_eq!(errors.len(), 3);
673        
674        // All errors should be about file not existing
675        for error in errors {
676            let error_msg = error.to_string();
677            assert!(error_msg.contains("does not exist") || error_msg.contains("Invalid"));
678        }
679    }
680}