1
//! ZIP file manipulation module for C API exposure
2
//!
3
//! Provides a ZipFile struct for reading/writing ZIP archives.
4

            
5
use alloc::string::String;
6
use alloc::vec::Vec;
7
use alloc::format;
8
use core::fmt;
9

            
10
#[cfg(feature = "std")]
11
use std::path::Path;
12

            
13
// ============================================================================
14
// Configuration types
15
// ============================================================================
16

            
17
/// Configuration for reading ZIP archives
18
#[derive(Debug, Clone, Default)]
19
#[repr(C)]
20
pub 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

            
29
impl 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)]
48
pub 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

            
59
impl Default for ZipWriteConfig {
60
2
    fn default() -> Self {
61
2
        Self {
62
2
            compression_method: 1, // Deflate
63
2
            compression_level: 6,  // Default compression
64
2
            unix_permissions: 0o644,
65
2
            comment: String::new(),
66
2
        }
67
2
    }
68
}
69

            
70
impl 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)]
104
pub 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
118
pub type ZipPathEntryVec = Vec<ZipPathEntry>;
119

            
120
/// File entry in a ZIP archive (with data, for writing)
121
#[derive(Debug, Clone)]
122
#[repr(C)]
123
pub 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

            
132
impl ZipFileEntry {
133
    /// Create a new file entry
134
6
    pub fn file(path: impl Into<String>, data: Vec<u8>) -> Self {
135
6
        Self {
136
6
            path: path.into(),
137
6
            data,
138
6
            is_directory: false,
139
6
        }
140
6
    }
141
    
142
    /// Create a new directory entry
143
1
    pub fn directory(path: impl Into<String>) -> Self {
144
1
        Self {
145
1
            path: path.into(),
146
1
            data: Vec::new(),
147
1
            is_directory: true,
148
1
        }
149
1
    }
150
}
151

            
152
/// Vec of ZipFileEntry  
153
pub 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)]
162
pub 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

            
177
impl 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")]
193
impl std::error::Error for ZipReadError {}
194

            
195
/// Error when writing ZIP archives
196
#[derive(Debug, Clone, PartialEq)]
197
#[repr(C, u8)]
198
pub enum ZipWriteError {
199
    /// I/O error
200
    IoError(String),
201
    /// Invalid path
202
    InvalidPath(String),
203
    /// Compression error
204
    CompressionError(String),
205
}
206

            
207
impl 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")]
218
impl 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)]
227
pub struct ZipFile {
228
    /// The entries in the archive
229
    pub entries: ZipFileEntryVec,
230
}
231

            
232
impl ZipFile {
233
    /// Create a new empty ZIP archive
234
1
    pub fn new() -> Self {
235
1
        Self {
236
1
            entries: Vec::new(),
237
1
        }
238
1
    }
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
1
    pub fn from_bytes(data: Vec<u8>, config: &ZipReadConfig) -> Result<Self, ZipReadError> {
335
        use std::io::{Cursor, Read};
336
        
337
1
        let cursor = Cursor::new(&data);
338
1
        let mut archive = zip::ZipArchive::new(cursor)
339
1
            .map_err(|e| ZipReadError::InvalidFormat(e.to_string()))?;
340
        
341
1
        let mut entries = Vec::new();
342
        
343
2
        for i in 0..archive.len() {
344
2
            let mut file = archive.by_index(i)
345
2
                .map_err(|e| ZipReadError::IoError(e.to_string()))?;
346
            
347
2
            let path = file.name().to_string();
348
            
349
            // Security check
350
2
            if !config.allow_path_traversal && path.contains("..") {
351
                return Err(ZipReadError::UnsafePath(path));
352
2
            }
353
            
354
            // Size check
355
2
            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
2
            }
362
            
363
2
            let is_directory = file.is_dir();
364
2
            let mut file_data = Vec::new();
365
            
366
2
            if !is_directory {
367
2
                file.read_to_end(&mut file_data)
368
2
                    .map_err(|e| ZipReadError::IoError(e.to_string()))?;
369
            }
370
            
371
2
            entries.push(ZipFileEntry {
372
2
                path,
373
2
                data: file_data,
374
2
                is_directory,
375
2
            });
376
        }
377
        
378
1
        Ok(Self { entries })
379
1
    }
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
1
    pub fn to_bytes(&self, config: &ZipWriteConfig) -> Result<Vec<u8>, ZipWriteError> {
395
        use std::io::{Cursor, Write};
396
        use zip::write::SimpleFileOptions;
397
        
398
1
        let buffer = Vec::new();
399
1
        let cursor = Cursor::new(buffer);
400
1
        let mut writer = zip::ZipWriter::new(cursor);
401
        
402
        // Set archive comment
403
1
        if !config.comment.is_empty() {
404
            writer.set_comment(config.comment.clone());
405
1
        }
406
        
407
1
        let compression = match config.compression_method {
408
            0 => zip::CompressionMethod::Stored,
409
1
            _ => zip::CompressionMethod::Deflated,
410
        };
411
        
412
1
        let options = SimpleFileOptions::default()
413
1
            .compression_method(compression)
414
1
            .compression_level(Some(config.compression_level as i64))
415
1
            .unix_permissions(config.unix_permissions);
416
        
417
3
        for entry in &self.entries {
418
2
            if entry.is_directory {
419
                writer.add_directory(&entry.path, options)
420
                    .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
421
            } else {
422
2
                writer.start_file(&entry.path, options)
423
2
                    .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
424
2
                writer.write_all(&entry.data)
425
2
                    .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
426
            }
427
        }
428
        
429
1
        let result = writer.finish()
430
1
            .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
431
        
432
1
        Ok(result.into_inner())
433
1
    }
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
3
    pub fn add_file(&mut self, path: impl Into<String>, data: Vec<u8>) {
450
3
        let path = path.into();
451
        // Remove existing entry with same path
452
3
        self.entries.retain(|e| e.path != path);
453
3
        self.entries.push(ZipFileEntry::file(path, data));
454
3
    }
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
1
    pub fn remove(&mut self, path: &str) {
465
2
        self.entries.retain(|e| e.path != path);
466
1
    }
467
    
468
    /// Get an entry by path
469
1
    pub fn get(&self, path: &str) -> Option<&ZipFileEntry> {
470
1
        self.entries.iter().find(|e| e.path == path)
471
1
    }
472
    
473
    /// Check if archive contains a path
474
3
    pub fn contains(&self, path: &str) -> bool {
475
4
        self.entries.iter().any(|e| e.path == path)
476
3
    }
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")]
497
1
pub fn zip_create(entries: Vec<ZipFileEntry>, config: &ZipWriteConfig) -> Result<Vec<u8>, ZipWriteError> {
498
1
    let zip = ZipFile { entries };
499
1
    zip.to_bytes(config)
500
1
}
501

            
502
/// Create a ZIP archive from path/data pairs (consumes entries, no clone)
503
#[cfg(feature = "zip_support")]
504
1
pub fn zip_create_from_files(
505
1
    files: Vec<(String, Vec<u8>)>, 
506
1
    config: &ZipWriteConfig,
507
1
) -> Result<Vec<u8>, ZipWriteError> {
508
1
    let entries: Vec<ZipFileEntry> = files
509
1
        .into_iter()
510
2
        .map(|(path, data)| ZipFileEntry::file(path, data))
511
1
        .collect();
512
1
    zip_create(entries, config)
513
1
}
514

            
515
/// Extract all files from ZIP data
516
#[cfg(feature = "zip_support")]
517
1
pub fn zip_extract_all(data: &[u8], config: &ZipReadConfig) -> Result<Vec<ZipFileEntry>, ZipReadError> {
518
1
    let zip = ZipFile::from_bytes(data.to_vec(), config)?;
519
1
    Ok(zip.entries)
520
1
}
521

            
522
/// List contents of ZIP data without extracting
523
#[cfg(feature = "zip_support")]
524
pub 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)]
533
mod tests {
534
    use super::*;
535
    
536
    #[test]
537
1
    fn test_zip_config_defaults() {
538
1
        let read_config = ZipReadConfig::default();
539
1
        assert_eq!(read_config.max_file_size, 0);
540
1
        assert!(!read_config.allow_path_traversal);
541
        
542
1
        let write_config = ZipWriteConfig::default();
543
1
        assert_eq!(write_config.compression_method, 1);
544
1
        assert_eq!(write_config.compression_level, 6);
545
1
    }
546
    
547
    #[test]
548
1
    fn test_zip_file_entry_creation() {
549
1
        let file = ZipFileEntry::file("test.txt", b"Hello".to_vec());
550
1
        assert_eq!(file.path, "test.txt");
551
1
        assert!(!file.is_directory);
552
1
        assert_eq!(file.data, b"Hello");
553
        
554
1
        let dir = ZipFileEntry::directory("subdir/");
555
1
        assert!(dir.is_directory);
556
1
        assert!(dir.data.is_empty());
557
1
    }
558
    
559
    #[cfg(feature = "zip_support")]
560
    #[test]
561
1
    fn test_zip_roundtrip() {
562
1
        let files = vec![
563
1
            ("hello.txt".to_string(), b"Hello, World!".to_vec()),
564
1
            ("sub/nested.txt".to_string(), b"Nested file".to_vec()),
565
        ];
566
        
567
1
        let write_config = ZipWriteConfig::default();
568
1
        let zip_data = zip_create_from_files(files, &write_config).expect("Failed to create ZIP");
569
        
570
1
        let read_config = ZipReadConfig::default();
571
1
        let entries = zip_extract_all(&zip_data, &read_config).expect("Failed to extract");
572
        
573
1
        assert_eq!(entries.len(), 2);
574
1
        assert!(entries.iter().any(|e| e.path == "hello.txt"));
575
2
        assert!(entries.iter().any(|e| e.path == "sub/nested.txt"));
576
1
    }
577
    
578
    #[cfg(feature = "zip_support")]
579
    #[test]
580
1
    fn test_zip_file_manipulation() {
581
1
        let mut zip = ZipFile::new();
582
        
583
1
        zip.add_file("a.txt", b"AAA".to_vec());
584
1
        zip.add_file("b.txt", b"BBB".to_vec());
585
        
586
1
        assert_eq!(zip.entries.len(), 2);
587
1
        assert!(zip.contains("a.txt"));
588
1
        assert!(zip.contains("b.txt"));
589
        
590
1
        zip.remove("a.txt");
591
1
        assert_eq!(zip.entries.len(), 1);
592
1
        assert!(!zip.contains("a.txt"));
593
        
594
        // Overwrite existing
595
1
        zip.add_file("b.txt", b"NEW".to_vec());
596
1
        assert_eq!(zip.entries.len(), 1);
597
1
        assert_eq!(zip.get("b.txt").unwrap().data, b"NEW");
598
1
    }
599
}