Skip to main content

azul_layout/
zip.rs

1//! ZIP file manipulation module for C API exposure
2//!
3//! Provides a ZipFile struct for reading/writing ZIP archives.
4
5use alloc::string::String;
6use alloc::vec::Vec;
7use alloc::format;
8use core::fmt;
9
10#[cfg(feature = "std")]
11use std::path::Path;
12
13// ============================================================================
14// Configuration types
15// ============================================================================
16
17/// Configuration for reading ZIP archives
18#[derive(Debug, Clone, Default)]
19#[repr(C)]
20pub struct ZipReadConfig {
21    /// Maximum file size to extract (0 = unlimited)
22    pub max_file_size: u64,
23    /// Whether to allow paths with ".." (path traversal) - default: false
24    pub allow_path_traversal: bool,
25    /// Whether to skip encrypted files instead of erroring - default: false  
26    pub skip_encrypted: bool,
27}
28
29impl ZipReadConfig {
30    pub fn new() -> Self {
31        Self::default()
32    }
33    
34    pub fn with_max_file_size(mut self, max_size: u64) -> Self {
35        self.max_file_size = max_size;
36        self
37    }
38    
39    pub fn with_allow_path_traversal(mut self, allow: bool) -> Self {
40        self.allow_path_traversal = allow;
41        self
42    }
43}
44
45/// Configuration for writing ZIP archives
46#[derive(Debug, Clone)]
47#[repr(C)]
48pub struct ZipWriteConfig {
49    /// Compression method: 0 = Store (no compression), 1 = Deflate
50    pub compression_method: u8,
51    /// Compression level (0-9, only for Deflate)
52    pub compression_level: u8,
53    /// Unix permissions for files (default: 0o644)
54    pub unix_permissions: u32,
55    /// Archive comment
56    pub comment: String,
57}
58
59impl Default for ZipWriteConfig {
60    fn default() -> Self {
61        Self {
62            compression_method: 1, // Deflate
63            compression_level: 6,  // Default compression
64            unix_permissions: 0o644,
65            comment: String::new(),
66        }
67    }
68}
69
70impl ZipWriteConfig {
71    pub fn new() -> Self {
72        Self::default()
73    }
74    
75    pub fn store() -> Self {
76        Self {
77            compression_method: 0,
78            ..Default::default()
79        }
80    }
81    
82    pub fn deflate(level: u8) -> Self {
83        Self {
84            compression_method: 1,
85            compression_level: level.min(9),
86            ..Default::default()
87        }
88    }
89    
90    pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
91        self.comment = comment.into();
92        self
93    }
94}
95
96// ============================================================================
97// Entry types
98// ============================================================================
99
100/// Path entry in a ZIP archive (metadata only, no data)
101#[derive(Debug, Clone)]
102#[repr(C)]
103pub struct ZipPathEntry {
104    /// File path within the archive
105    pub path: String,
106    /// Whether this is a directory
107    pub is_directory: bool,
108    /// Uncompressed size in bytes
109    pub size: u64,
110    /// Compressed size in bytes
111    pub compressed_size: u64,
112    /// CRC32 checksum
113    pub crc32: u32,
114}
115
116/// Vec of ZipPathEntry
117pub type ZipPathEntryVec = Vec<ZipPathEntry>;
118
119/// File entry in a ZIP archive (with data, for writing)
120#[derive(Debug, Clone)]
121#[repr(C)]
122pub struct ZipFileEntry {
123    /// File path within the archive
124    pub path: String,
125    /// File contents (empty for directories)
126    pub data: Vec<u8>,
127    /// Whether this is a directory
128    pub is_directory: bool,
129}
130
131impl ZipFileEntry {
132    /// Create a new file entry
133    pub fn file(path: impl Into<String>, data: Vec<u8>) -> Self {
134        Self {
135            path: path.into(),
136            data,
137            is_directory: false,
138        }
139    }
140    
141    /// Create a new directory entry
142    pub fn directory(path: impl Into<String>) -> Self {
143        Self {
144            path: path.into(),
145            data: Vec::new(),
146            is_directory: true,
147        }
148    }
149}
150
151/// Vec of ZipFileEntry  
152pub type ZipFileEntryVec = Vec<ZipFileEntry>;
153
154// ============================================================================
155// Error types
156// ============================================================================
157
158/// Error when reading ZIP archives
159#[derive(Debug, Clone, PartialEq)]
160#[repr(C, u8)]
161pub enum ZipReadError {
162    /// Invalid ZIP format
163    InvalidFormat(String),
164    /// File not found in archive
165    FileNotFound(String),
166    /// I/O error
167    IoError(String),
168    /// Path traversal attack detected
169    UnsafePath(String),
170    /// File is encrypted (unsupported)
171    EncryptedFile(String),
172    /// File too large
173    FileTooLarge { path: String, size: u64, max_size: u64 },
174}
175
176impl fmt::Display for ZipReadError {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        match self {
179            ZipReadError::InvalidFormat(msg) => write!(f, "Invalid ZIP format: {}", msg),
180            ZipReadError::FileNotFound(path) => write!(f, "File not found: {}", path),
181            ZipReadError::IoError(msg) => write!(f, "I/O error: {}", msg),
182            ZipReadError::UnsafePath(path) => write!(f, "Unsafe path: {}", path),
183            ZipReadError::EncryptedFile(path) => write!(f, "Encrypted file: {}", path),
184            ZipReadError::FileTooLarge { path, size, max_size } => {
185                write!(f, "File too large: {} ({} > {})", path, size, max_size)
186            }
187        }
188    }
189}
190
191#[cfg(feature = "std")]
192impl std::error::Error for ZipReadError {}
193
194/// Error when writing ZIP archives
195#[derive(Debug, Clone, PartialEq)]
196#[repr(C, u8)]
197pub enum ZipWriteError {
198    /// I/O error
199    IoError(String),
200    /// Invalid path
201    InvalidPath(String),
202    /// Compression error
203    CompressionError(String),
204}
205
206impl fmt::Display for ZipWriteError {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        match self {
209            ZipWriteError::IoError(msg) => write!(f, "I/O error: {}", msg),
210            ZipWriteError::InvalidPath(path) => write!(f, "Invalid path: {}", path),
211            ZipWriteError::CompressionError(msg) => write!(f, "Compression error: {}", msg),
212        }
213    }
214}
215
216#[cfg(feature = "std")]
217impl std::error::Error for ZipWriteError {}
218
219// ============================================================================
220// ZipFile struct
221// ============================================================================
222
223/// A ZIP archive that can be read from or written to
224#[derive(Debug, Clone, Default)]
225#[repr(C)]
226pub struct ZipFile {
227    /// The entries in the archive
228    pub entries: ZipFileEntryVec,
229}
230
231impl ZipFile {
232    /// Create a new empty ZIP archive
233    pub fn new() -> Self {
234        Self {
235            entries: Vec::new(),
236        }
237    }
238    
239    /// List contents of a ZIP archive without loading file data
240    /// 
241    /// # Arguments
242    /// * `data` - ZIP file bytes
243    /// * `config` - Read configuration
244    /// 
245    /// # Returns
246    /// List of path entries (metadata only)
247    #[cfg(feature = "zip_support")]
248    pub fn list(data: &[u8], config: &ZipReadConfig) -> Result<ZipPathEntryVec, ZipReadError> {
249        use std::io::Cursor;
250        
251        let cursor = Cursor::new(data);
252        let mut archive = zip::ZipArchive::new(cursor)
253            .map_err(|e| ZipReadError::InvalidFormat(e.to_string()))?;
254        
255        let mut entries = Vec::new();
256        
257        for i in 0..archive.len() {
258            let file = archive.by_index(i)
259                .map_err(|e| ZipReadError::IoError(e.to_string()))?;
260            
261            let path = file.name().to_string();
262            
263            // Security check
264            if !config.allow_path_traversal && path.contains("..") {
265                return Err(ZipReadError::UnsafePath(path));
266            }
267            
268            entries.push(ZipPathEntry {
269                path,
270                is_directory: file.is_dir(),
271                size: file.size(),
272                compressed_size: file.compressed_size(),
273                crc32: file.crc32(),
274            });
275        }
276        
277        Ok(entries)
278    }
279    
280    /// Extract a single file from ZIP data
281    /// 
282    /// # Arguments
283    /// * `data` - ZIP file bytes
284    /// * `entry` - The path entry to extract
285    /// * `config` - Read configuration
286    /// 
287    /// # Returns
288    /// The file contents, or None if not found
289    #[cfg(feature = "zip_support")]
290    pub fn get_single_file(
291        data: &[u8], 
292        entry: &ZipPathEntry,
293        config: &ZipReadConfig,
294    ) -> Result<Option<Vec<u8>>, ZipReadError> {
295        use std::io::{Cursor, Read};
296        
297        // Size check
298        if config.max_file_size > 0 && entry.size > config.max_file_size {
299            return Err(ZipReadError::FileTooLarge {
300                path: entry.path.clone(),
301                size: entry.size,
302                max_size: config.max_file_size,
303            });
304        }
305        
306        let cursor = Cursor::new(data);
307        let mut archive = zip::ZipArchive::new(cursor)
308            .map_err(|e| ZipReadError::InvalidFormat(e.to_string()))?;
309        
310        let mut file = match archive.by_name(&entry.path) {
311            Ok(f) => f,
312            Err(zip::result::ZipError::FileNotFound) => return Ok(None),
313            Err(e) => return Err(ZipReadError::IoError(e.to_string())),
314        };
315        
316        if file.is_dir() {
317            return Ok(Some(Vec::new()));
318        }
319        
320        let mut contents = Vec::with_capacity(entry.size as usize);
321        file.read_to_end(&mut contents)
322            .map_err(|e| ZipReadError::IoError(e.to_string()))?;
323        
324        Ok(Some(contents))
325    }
326    
327    /// Load a ZIP archive from bytes
328    /// 
329    /// # Arguments
330    /// * `data` - ZIP file bytes (consumed)
331    /// * `config` - Read configuration
332    #[cfg(feature = "zip_support")]
333    pub fn from_bytes(data: Vec<u8>, config: &ZipReadConfig) -> Result<Self, ZipReadError> {
334        use std::io::{Cursor, Read};
335        
336        let cursor = Cursor::new(&data);
337        let mut archive = zip::ZipArchive::new(cursor)
338            .map_err(|e| ZipReadError::InvalidFormat(e.to_string()))?;
339        
340        let mut entries = Vec::new();
341        
342        for i in 0..archive.len() {
343            let mut file = archive.by_index(i)
344                .map_err(|e| ZipReadError::IoError(e.to_string()))?;
345            
346            let path = file.name().to_string();
347            
348            // Security check
349            if !config.allow_path_traversal && path.contains("..") {
350                return Err(ZipReadError::UnsafePath(path));
351            }
352            
353            // Size check
354            if config.max_file_size > 0 && file.size() > config.max_file_size {
355                return Err(ZipReadError::FileTooLarge {
356                    path,
357                    size: file.size(),
358                    max_size: config.max_file_size,
359                });
360            }
361            
362            let is_directory = file.is_dir();
363            let mut file_data = Vec::new();
364            
365            if !is_directory {
366                file.read_to_end(&mut file_data)
367                    .map_err(|e| ZipReadError::IoError(e.to_string()))?;
368            }
369            
370            entries.push(ZipFileEntry {
371                path,
372                data: file_data,
373                is_directory,
374            });
375        }
376        
377        Ok(Self { entries })
378    }
379    
380    /// Load a ZIP archive from a file path
381    #[cfg(all(feature = "zip_support", feature = "std"))]
382    pub fn from_file(path: &Path, config: &ZipReadConfig) -> Result<Self, ZipReadError> {
383        let data = std::fs::read(path)
384            .map_err(|e| ZipReadError::IoError(e.to_string()))?;
385        Self::from_bytes(data, config)
386    }
387    
388    /// Write the ZIP archive to bytes
389    /// 
390    /// # Arguments
391    /// * `config` - Write configuration
392    #[cfg(feature = "zip_support")]
393    pub fn to_bytes(&self, config: &ZipWriteConfig) -> Result<Vec<u8>, ZipWriteError> {
394        use std::io::{Cursor, Write};
395        use zip::write::SimpleFileOptions;
396        
397        let buffer = Vec::new();
398        let cursor = Cursor::new(buffer);
399        let mut writer = zip::ZipWriter::new(cursor);
400        
401        // Set archive comment
402        if !config.comment.is_empty() {
403            writer.set_comment(config.comment.clone());
404        }
405        
406        let compression = match config.compression_method {
407            0 => zip::CompressionMethod::Stored,
408            _ => zip::CompressionMethod::Deflated,
409        };
410        
411        let options = SimpleFileOptions::default()
412            .compression_method(compression)
413            .unix_permissions(config.unix_permissions);
414        
415        for entry in &self.entries {
416            if entry.is_directory {
417                writer.add_directory(&entry.path, options)
418                    .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
419            } else {
420                writer.start_file(&entry.path, options)
421                    .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
422                writer.write_all(&entry.data)
423                    .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
424            }
425        }
426        
427        let result = writer.finish()
428            .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
429        
430        Ok(result.into_inner())
431    }
432    
433    /// Write the ZIP archive to a file
434    #[cfg(all(feature = "zip_support", feature = "std"))]
435    pub fn to_file(&self, path: &Path, config: &ZipWriteConfig) -> Result<(), ZipWriteError> {
436        let data = self.to_bytes(config)?;
437        std::fs::write(path, data)
438            .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
439        Ok(())
440    }
441    
442    // ========================================================================
443    // Convenience methods for modifying the archive
444    // ========================================================================
445    
446    /// Add a file entry (consumes the data, no clone)
447    pub fn add_file(&mut self, path: impl Into<String>, data: Vec<u8>) {
448        let path = path.into();
449        // Remove existing entry with same path
450        self.entries.retain(|e| e.path != path);
451        self.entries.push(ZipFileEntry::file(path, data));
452    }
453    
454    /// Add a directory entry
455    pub fn add_directory(&mut self, path: impl Into<String>) {
456        let path = path.into();
457        self.entries.retain(|e| e.path != path);
458        self.entries.push(ZipFileEntry::directory(path));
459    }
460    
461    /// Remove an entry by path
462    pub fn remove(&mut self, path: &str) {
463        self.entries.retain(|e| e.path != path);
464    }
465    
466    /// Get an entry by path
467    pub fn get(&self, path: &str) -> Option<&ZipFileEntry> {
468        self.entries.iter().find(|e| e.path == path)
469    }
470    
471    /// Check if archive contains a path
472    pub fn contains(&self, path: &str) -> bool {
473        self.entries.iter().any(|e| e.path == path)
474    }
475    
476    /// Get list of all paths
477    pub fn paths(&self) -> Vec<&str> {
478        self.entries.iter().map(|e| e.path.as_str()).collect()
479    }
480    
481    /// Filter entries by suffix (e.g., ".fluent", ".json")
482    pub fn filter_by_suffix(&self, suffix: &str) -> Vec<&ZipFileEntry> {
483        self.entries.iter()
484            .filter(|e| !e.is_directory && e.path.ends_with(suffix))
485            .collect()
486    }
487}
488
489// ============================================================================
490// Convenience functions (for simpler use cases)
491// ============================================================================
492
493/// Create a ZIP archive from file entries (consumes entries, no clone)
494#[cfg(feature = "zip_support")]
495pub fn zip_create(entries: Vec<ZipFileEntry>, config: &ZipWriteConfig) -> Result<Vec<u8>, ZipWriteError> {
496    let zip = ZipFile { entries };
497    zip.to_bytes(config)
498}
499
500/// Create a ZIP archive from path/data pairs (consumes entries, no clone)
501#[cfg(feature = "zip_support")]
502pub fn zip_create_from_files(
503    files: Vec<(String, Vec<u8>)>, 
504    config: &ZipWriteConfig,
505) -> Result<Vec<u8>, ZipWriteError> {
506    let entries: Vec<ZipFileEntry> = files
507        .into_iter()
508        .map(|(path, data)| ZipFileEntry::file(path, data))
509        .collect();
510    zip_create(entries, config)
511}
512
513/// Extract all files from ZIP data
514#[cfg(feature = "zip_support")]
515pub fn zip_extract_all(data: &[u8], config: &ZipReadConfig) -> Result<Vec<ZipFileEntry>, ZipReadError> {
516    let zip = ZipFile::from_bytes(data.to_vec(), config)?;
517    Ok(zip.entries)
518}
519
520/// List contents of ZIP data without extracting
521#[cfg(feature = "zip_support")]
522pub fn zip_list_contents(data: &[u8], config: &ZipReadConfig) -> Result<Vec<ZipPathEntry>, ZipReadError> {
523    ZipFile::list(data, config)
524}
525
526// ============================================================================
527// Tests
528// ============================================================================
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    
534    #[test]
535    fn test_zip_config_defaults() {
536        let read_config = ZipReadConfig::default();
537        assert_eq!(read_config.max_file_size, 0);
538        assert!(!read_config.allow_path_traversal);
539        
540        let write_config = ZipWriteConfig::default();
541        assert_eq!(write_config.compression_method, 1);
542        assert_eq!(write_config.compression_level, 6);
543    }
544    
545    #[test]
546    fn test_zip_file_entry_creation() {
547        let file = ZipFileEntry::file("test.txt", b"Hello".to_vec());
548        assert_eq!(file.path, "test.txt");
549        assert!(!file.is_directory);
550        assert_eq!(file.data, b"Hello");
551        
552        let dir = ZipFileEntry::directory("subdir/");
553        assert!(dir.is_directory);
554        assert!(dir.data.is_empty());
555    }
556    
557    #[cfg(feature = "zip_support")]
558    #[test]
559    fn test_zip_roundtrip() {
560        let files = vec![
561            ("hello.txt".to_string(), b"Hello, World!".to_vec()),
562            ("sub/nested.txt".to_string(), b"Nested file".to_vec()),
563        ];
564        
565        let write_config = ZipWriteConfig::default();
566        let zip_data = zip_create_from_files(files, &write_config).expect("Failed to create ZIP");
567        
568        let read_config = ZipReadConfig::default();
569        let entries = zip_extract_all(&zip_data, &read_config).expect("Failed to extract");
570        
571        assert_eq!(entries.len(), 2);
572        assert!(entries.iter().any(|e| e.path == "hello.txt"));
573        assert!(entries.iter().any(|e| e.path == "sub/nested.txt"));
574    }
575    
576    #[cfg(feature = "zip_support")]
577    #[test]
578    fn test_zip_file_manipulation() {
579        let mut zip = ZipFile::new();
580        
581        zip.add_file("a.txt", b"AAA".to_vec());
582        zip.add_file("b.txt", b"BBB".to_vec());
583        
584        assert_eq!(zip.entries.len(), 2);
585        assert!(zip.contains("a.txt"));
586        assert!(zip.contains("b.txt"));
587        
588        zip.remove("a.txt");
589        assert_eq!(zip.entries.len(), 1);
590        assert!(!zip.contains("a.txt"));
591        
592        // Overwrite existing
593        zip.add_file("b.txt", b"NEW".to_vec());
594        assert_eq!(zip.entries.len(), 1);
595        assert_eq!(zip.get("b.txt").unwrap().data, b"NEW");
596    }
597}