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