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