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}
631
632// ============================================================================
633// Autotest: adversarial tests
634// ============================================================================
635
636#[cfg(test)]
637mod autotest_generated {
638    use super::*;
639
640    // ------------------------------------------------------------------
641    // helpers
642    // ------------------------------------------------------------------
643
644    /// A ZIP that this module can actually produce: default (Deflate/6) config.
645    #[cfg(feature = "zip")]
646    fn build(entries: Vec<ZipFileEntry>) -> Vec<u8> {
647        zip_create(entries, &ZipWriteConfig::default()).expect("default write config must work")
648    }
649
650    /// Hand-rolled 22-byte "end of central directory" record = an empty archive.
651    #[cfg(feature = "zip")]
652    fn eocd_only() -> Vec<u8> {
653        let mut v = vec![0x50, 0x4B, 0x05, 0x06];
654        v.extend_from_slice(&[0u8; 18]);
655        v
656    }
657
658    /// Adversarial path strings reused across the lookup tests.
659    fn nasty_paths() -> Vec<String> {
660        vec![
661            String::new(),
662            "   ".to_string(),
663            "\t\n".to_string(),
664            "\0".to_string(),
665            "a\0b".to_string(),
666            "..".to_string(),
667            "../../etc/passwd".to_string(),
668            "./a.txt".to_string(),
669            "a.txt ".to_string(),
670            " a.txt".to_string(),
671            "a.txt;garbage".to_string(),
672            "0".to_string(),
673            "-0".to_string(),
674            "NaN".to_string(),
675            "inf".to_string(),
676            "-inf".to_string(),
677            "9223372036854775807".to_string(),
678            "-9223372036854775808".to_string(),
679            "18446744073709551615".to_string(),
680            "1e309".to_string(),
681            "\u{1F600}".to_string(),
682            "e\u{0301}\u{0301}\u{0301}.txt".to_string(),
683            "\u{202E}txt.exe".to_string(),
684            "\u{FEFF}a.txt".to_string(),
685            "A/".repeat(2000),
686            "x".repeat(100_000),
687        ]
688    }
689
690    // ==================================================================
691    // constructors / config (feature-independent)
692    // ==================================================================
693
694    #[test]
695    fn autotest_read_config_builders_at_numeric_extremes() {
696        let base = ZipReadConfig::new();
697        let def = ZipReadConfig::default();
698        assert_eq!(base.max_file_size, def.max_file_size);
699        assert_eq!(base.allow_path_traversal, def.allow_path_traversal);
700        assert_eq!(base.skip_encrypted, def.skip_encrypted);
701        assert_eq!(base.max_file_size, 0);
702        assert!(!base.allow_path_traversal);
703        assert!(!base.skip_encrypted);
704
705        for size in [0u64, 1, u64::from(u32::MAX), u64::MAX / 2, u64::MAX - 1, u64::MAX] {
706            let c = ZipReadConfig::new().with_max_file_size(size);
707            assert_eq!(c.max_file_size, size);
708            // the other fields must not be perturbed by the builder
709            assert!(!c.allow_path_traversal);
710            assert!(!c.skip_encrypted);
711        }
712
713        for allow in [false, true] {
714            let c = ZipReadConfig::new()
715                .with_max_file_size(u64::MAX)
716                .with_allow_path_traversal(allow);
717            assert_eq!(c.allow_path_traversal, allow);
718            assert_eq!(c.max_file_size, u64::MAX);
719        }
720
721        // builders are order-independent and idempotent
722        let a = ZipReadConfig::new().with_max_file_size(7).with_allow_path_traversal(true);
723        let b = ZipReadConfig::new().with_allow_path_traversal(true).with_max_file_size(7);
724        assert_eq!(a.max_file_size, b.max_file_size);
725        assert_eq!(a.allow_path_traversal, b.allow_path_traversal);
726        let c = a.with_max_file_size(7);
727        assert_eq!(c.max_file_size, 7);
728        assert!(c.allow_path_traversal);
729
730        // ZipReadConfig is Copy: the "consumed" value is still usable
731        let orig = ZipReadConfig::new();
732        let _moved = orig.with_max_file_size(99);
733        assert_eq!(orig.max_file_size, 0);
734    }
735
736    #[test]
737    fn autotest_write_config_new_store_and_defaults() {
738        let new = ZipWriteConfig::new();
739        let def = ZipWriteConfig::default();
740        assert_eq!(new.compression_method, def.compression_method);
741        assert_eq!(new.compression_level, def.compression_level);
742        assert_eq!(new.unix_permissions, def.unix_permissions);
743        assert_eq!(new.comment, def.comment);
744        assert_eq!(new.compression_method, 1);
745        assert_eq!(new.compression_level, 6);
746        assert_eq!(new.unix_permissions, 0o644);
747        assert!(new.comment.is_empty());
748
749        let store = ZipWriteConfig::store();
750        assert_eq!(store.compression_method, 0);
751        assert_eq!(store.compression_level, 0);
752        // store() only overrides the two compression fields
753        assert_eq!(store.unix_permissions, 0o644);
754        assert!(store.comment.is_empty());
755    }
756
757    #[test]
758    fn autotest_write_config_deflate_saturates_level() {
759        // documented clamp is `level.min(9)`; verify across the whole u8 domain
760        for level in 0u16..=255 {
761            let level = u8::try_from(level).unwrap();
762            let cfg = ZipWriteConfig::deflate(level);
763            assert_eq!(cfg.compression_method, 1, "deflate() must always select Deflate");
764            assert_eq!(
765                cfg.compression_level,
766                level.min(9),
767                "deflate({level}) did not saturate at 9"
768            );
769            assert!(cfg.compression_level <= 9);
770        }
771        // explicit boundary spot checks
772        assert_eq!(ZipWriteConfig::deflate(0).compression_level, 0);
773        assert_eq!(ZipWriteConfig::deflate(9).compression_level, 9);
774        assert_eq!(ZipWriteConfig::deflate(10).compression_level, 9);
775        assert_eq!(ZipWriteConfig::deflate(u8::MIN).compression_level, 0);
776        assert_eq!(ZipWriteConfig::deflate(u8::MAX).compression_level, 9);
777    }
778
779    #[test]
780    fn autotest_write_config_with_comment_extremes() {
781        // empty
782        let c = ZipWriteConfig::new().with_comment("");
783        assert!(c.comment.is_empty());
784
785        // unicode + control chars + NUL are stored verbatim (no sanitising)
786        for s in [
787            "\u{1F600}\u{1F9F0}",
788            "e\u{0301}combining",
789            "line1\nline2\r\n",
790            "nul\0inside",
791            "\u{202E}rtl",
792        ] {
793            let c = ZipWriteConfig::new().with_comment(s);
794            assert_eq!(c.comment, s);
795            assert_eq!(c.comment.chars().count(), s.chars().count());
796        }
797
798        // very long comment (well past the u16 EOCD comment-length field)
799        let huge = "z".repeat(200_000);
800        let c = ZipWriteConfig::new().with_comment(huge.clone());
801        assert_eq!(c.comment.len(), 200_000);
802        assert_eq!(c.comment, huge);
803        // other fields untouched
804        assert_eq!(c.compression_method, 1);
805        assert_eq!(c.compression_level, 6);
806
807        // with_comment accepts both &str and String, and last write wins
808        let c = ZipWriteConfig::store().with_comment("a").with_comment(String::from("b"));
809        assert_eq!(c.comment, "b");
810        assert_eq!(c.compression_method, 0);
811    }
812
813    #[test]
814    fn autotest_zip_file_entry_constructors_no_panic() {
815        // empty path
816        let e = ZipFileEntry::file("", Vec::new());
817        assert!(e.path.is_empty());
818        assert!(e.data.is_empty());
819        assert!(!e.is_directory);
820
821        // path/data extremes
822        let long_path = "p".repeat(200_000);
823        let e = ZipFileEntry::file(long_path.clone(), vec![0xFFu8; 4096]);
824        assert_eq!(e.path, long_path);
825        assert_eq!(e.data.len(), 4096);
826        assert!(!e.is_directory);
827
828        // non-UTF8-looking bytes as *data* are fine (data is Vec<u8>)
829        let e = ZipFileEntry::file("bin", vec![0xFFu8, 0xFE, 0x00, 0x80]);
830        assert_eq!(e.data, vec![0xFFu8, 0xFE, 0x00, 0x80]);
831
832        // directory() always discards data and flags is_directory
833        for p in nasty_paths() {
834            let d = ZipFileEntry::directory(p.clone());
835            assert_eq!(d.path, p);
836            assert!(d.is_directory);
837            assert!(d.data.is_empty());
838        }
839
840        // constructors never rewrite the path (no trailing-slash normalisation)
841        assert_eq!(ZipFileEntry::directory("sub").path, "sub");
842        assert_eq!(ZipFileEntry::directory("sub/").path, "sub/");
843    }
844
845    // ==================================================================
846    // Display / error serialisation
847    // ==================================================================
848
849    #[test]
850    fn autotest_read_error_display_all_variants_non_empty() {
851        let cases = vec![
852            (ZipReadError::InvalidFormat("bad magic".into()), "bad magic"),
853            (ZipReadError::FileNotFound("a.txt".into()), "a.txt"),
854            (ZipReadError::IoError("eof".into()), "eof"),
855            (ZipReadError::UnsafePath("../x".into()), "../x"),
856            (ZipReadError::EncryptedFile("s.bin".into()), "s.bin"),
857            (
858                ZipReadError::FileTooLarge {
859                    path: "big".into(),
860                    size: 10,
861                    max_size: 5,
862                },
863                "big",
864            ),
865        ];
866        for (err, needle) in cases {
867            let s = err.to_string();
868            assert!(!s.is_empty(), "empty Display for {err:?}");
869            assert!(s.contains(needle), "Display {s:?} lost payload {needle:?}");
870            // Debug must also be non-empty and must not equal Display
871            assert!(!format!("{err:?}").is_empty());
872        }
873    }
874
875    #[test]
876    fn autotest_read_error_display_edge_payloads() {
877        // empty payloads still produce a non-empty, prefixed message
878        for err in [
879            ZipReadError::InvalidFormat(String::new()),
880            ZipReadError::FileNotFound(String::new()),
881            ZipReadError::IoError(String::new()),
882            ZipReadError::UnsafePath(String::new()),
883            ZipReadError::EncryptedFile(String::new()),
884        ] {
885            let s = err.to_string();
886            assert!(!s.is_empty(), "empty payload produced empty Display");
887            assert!(s.contains(':'), "expected a prefixed message, got {s:?}");
888        }
889
890        // u64 boundaries in FileTooLarge
891        for (size, max_size) in [
892            (0u64, 0u64),
893            (0, u64::MAX),
894            (u64::MAX, 0),
895            (u64::MAX, u64::MAX),
896            (u64::MAX - 1, u64::MAX),
897        ] {
898            let err = ZipReadError::FileTooLarge {
899                path: "\u{1F600}/p".into(),
900                size,
901                max_size,
902            };
903            let s = err.to_string();
904            assert!(s.contains(&format!("{size}")));
905            assert!(s.contains(&format!("{max_size}")));
906            assert!(s.contains("\u{1F600}"));
907        }
908
909        // unicode / control / NUL payloads round-trip through Display unchanged
910        for payload in ["\u{1F600}", "e\u{0301}", "a\0b", "line\nbreak", &"L".repeat(50_000)] {
911            let err = ZipReadError::UnsafePath(payload.to_string());
912            assert!(err.to_string().contains(payload));
913        }
914    }
915
916    #[test]
917    fn autotest_write_error_display_all_variants_non_empty() {
918        let cases = vec![
919            (ZipWriteError::IoError("disk full".into()), "disk full"),
920            (ZipWriteError::InvalidPath("\u{1F600}".into()), "\u{1F600}"),
921            (ZipWriteError::CompressionError("level".into()), "level"),
922        ];
923        for (err, needle) in cases {
924            let s = err.to_string();
925            assert!(!s.is_empty());
926            assert!(s.contains(needle));
927            assert!(s.contains(':'));
928        }
929
930        for err in [
931            ZipWriteError::IoError(String::new()),
932            ZipWriteError::InvalidPath(String::new()),
933            ZipWriteError::CompressionError(String::new()),
934        ] {
935            assert!(!err.to_string().is_empty());
936        }
937
938        // huge + NUL payloads do not panic
939        let big = ZipWriteError::CompressionError("\0".to_string() + &"q".repeat(100_000));
940        assert!(big.to_string().len() >= 100_000);
941    }
942
943    #[test]
944    fn autotest_error_equality_and_std_error_impls() {
945        assert_eq!(
946            ZipReadError::UnsafePath("a".into()),
947            ZipReadError::UnsafePath("a".into())
948        );
949        assert_ne!(
950            ZipReadError::UnsafePath("a".into()),
951            ZipReadError::FileNotFound("a".into())
952        );
953        assert_ne!(
954            ZipReadError::FileTooLarge { path: "p".into(), size: 1, max_size: 2 },
955            ZipReadError::FileTooLarge { path: "p".into(), size: 1, max_size: 3 }
956        );
957        assert_eq!(
958            ZipWriteError::IoError("x".into()),
959            ZipWriteError::IoError("x".into())
960        );
961        assert_ne!(
962            ZipWriteError::IoError("x".into()),
963            ZipWriteError::InvalidPath("x".into())
964        );
965
966        // Clone must preserve equality
967        let e = ZipReadError::FileTooLarge {
968            path: "p".into(),
969            size: u64::MAX,
970            max_size: 0,
971        };
972        assert_eq!(e.clone(), e);
973
974        #[cfg(feature = "std")]
975        {
976            let r: &dyn std::error::Error = &e;
977            assert!(!r.to_string().is_empty());
978            let w = ZipWriteError::IoError("x".into());
979            let r: &dyn std::error::Error = &w;
980            assert!(!r.to_string().is_empty());
981        }
982    }
983
984    // ==================================================================
985    // in-memory ZipFile invariants (feature-independent)
986    // ==================================================================
987
988    #[test]
989    fn autotest_zipfile_new_and_default_are_empty() {
990        let a = ZipFile::new();
991        let b = ZipFile::default();
992        assert!(a.entries.is_empty());
993        assert!(b.entries.is_empty());
994        assert!(a.paths().is_empty());
995        assert!(a.filter_by_suffix("").is_empty());
996        assert!(a.filter_by_suffix(".txt").is_empty());
997        assert!(a.get("").is_none());
998        assert!(!a.contains(""));
999
1000        // every adversarial lookup on an empty archive is None/false, never a panic
1001        for p in nasty_paths() {
1002            assert!(a.get(&p).is_none());
1003            assert!(!a.contains(&p));
1004        }
1005
1006        // remove on an empty archive is a no-op
1007        let mut c = ZipFile::new();
1008        c.remove("nope");
1009        c.remove("");
1010        assert!(c.entries.is_empty());
1011    }
1012
1013    #[test]
1014    fn autotest_add_file_dedup_keeps_last_write() {
1015        let mut zip = ZipFile::new();
1016        zip.add_file("a", b"1".to_vec());
1017        zip.add_file("b", b"2".to_vec());
1018        zip.add_file("a", b"3".to_vec());
1019        assert_eq!(zip.entries.len(), 2);
1020        assert_eq!(zip.get("a").unwrap().data, b"3");
1021        // the replaced entry is re-appended at the end, so order changes
1022        assert_eq!(zip.paths(), vec!["b", "a"]);
1023
1024        // repeated writes to the same path never grow the archive
1025        for i in 0..100u32 {
1026            zip.add_file("a", format!("{i}").into_bytes());
1027        }
1028        assert_eq!(zip.entries.len(), 2);
1029        assert_eq!(zip.get("a").unwrap().data, b"99");
1030    }
1031
1032    #[test]
1033    fn autotest_add_directory_and_add_file_share_the_path_namespace() {
1034        let mut zip = ZipFile::new();
1035        zip.add_file("x", b"data".to_vec());
1036        assert!(!zip.get("x").unwrap().is_directory);
1037
1038        // add_directory replaces a file at the same path
1039        zip.add_directory("x");
1040        assert_eq!(zip.entries.len(), 1);
1041        assert!(zip.get("x").unwrap().is_directory);
1042        assert!(zip.get("x").unwrap().data.is_empty());
1043
1044        // ...and vice versa
1045        zip.add_file("x", b"back".to_vec());
1046        assert_eq!(zip.entries.len(), 1);
1047        assert!(!zip.get("x").unwrap().is_directory);
1048        assert_eq!(zip.get("x").unwrap().data, b"back");
1049
1050        // "x" and "x/" are *different* paths at this layer
1051        zip.add_directory("x/");
1052        assert_eq!(zip.entries.len(), 2);
1053        assert!(zip.contains("x"));
1054        assert!(zip.contains("x/"));
1055    }
1056
1057    #[test]
1058    fn autotest_add_and_remove_adversarial_paths_no_panic() {
1059        let mut zip = ZipFile::new();
1060        let paths = nasty_paths();
1061        for (i, p) in paths.iter().enumerate() {
1062            zip.add_file(p.clone(), vec![u8::try_from(i % 256).unwrap()]);
1063        }
1064        // nasty_paths() has no duplicates, so every path survived
1065        assert_eq!(zip.entries.len(), paths.len());
1066        for p in &paths {
1067            assert!(zip.contains(p), "lost path {p:?}");
1068            assert!(zip.get(p).is_some());
1069        }
1070        for p in &paths {
1071            zip.remove(p);
1072            assert!(!zip.contains(p));
1073        }
1074        assert!(zip.entries.is_empty());
1075
1076        // removing a path that is a *prefix*/*suffix* of a stored path must not match
1077        let mut zip = ZipFile::new();
1078        zip.add_file("dir/file.txt", b"d".to_vec());
1079        zip.remove("dir/");
1080        zip.remove("file.txt");
1081        zip.remove("dir/file.tx");
1082        zip.remove("dir/file.txt ");
1083        assert_eq!(zip.entries.len(), 1, "remove() must match the whole path only");
1084        zip.remove("dir/file.txt");
1085        assert!(zip.entries.is_empty());
1086    }
1087
1088    #[test]
1089    fn autotest_get_and_contains_agree_and_reject_junk() {
1090        let mut zip = ZipFile::new();
1091        zip.add_file("a.txt", b"A".to_vec());
1092        zip.add_file("\u{1F600}.txt", b"E".to_vec());
1093        zip.add_directory("sub/");
1094
1095        // exact matches only
1096        assert!(zip.contains("a.txt"));
1097        assert!(zip.contains("\u{1F600}.txt"));
1098        assert!(zip.contains("sub/"));
1099
1100        // leading/trailing junk, case changes and near-misses are all rejected
1101        for p in [
1102            " a.txt", "a.txt ", "A.TXT", "a.txt\0", "./a.txt", "/a.txt", "a.txt;x", "sub", "sub//",
1103            "\u{1F600}", "\u{1F600}.TXT",
1104        ] {
1105            assert!(!zip.contains(p), "unexpected match for {p:?}");
1106            assert!(zip.get(p).is_none());
1107        }
1108
1109        // get()/contains() must never disagree, for any input
1110        for p in nasty_paths() {
1111            assert_eq!(zip.get(&p).is_some(), zip.contains(&p), "disagree on {p:?}");
1112        }
1113
1114        // a 1M-char probe neither panics nor hangs
1115        let huge = "y".repeat(1_000_000);
1116        assert!(zip.get(&huge).is_none());
1117        assert!(!zip.contains(&huge));
1118    }
1119
1120    #[test]
1121    fn autotest_paths_mirrors_entries_in_order() {
1122        let mut zip = ZipFile::new();
1123        assert!(zip.paths().is_empty());
1124
1125        for i in 0..50u32 {
1126            zip.add_file(format!("f{i}"), vec![u8::try_from(i).unwrap()]);
1127        }
1128        zip.add_directory("d/");
1129
1130        let paths = zip.paths();
1131        assert_eq!(paths.len(), zip.entries.len());
1132        for (p, e) in paths.iter().zip(zip.entries.iter()) {
1133            assert_eq!(*p, e.path.as_str());
1134        }
1135        // directories are included in paths()
1136        assert!(paths.contains(&"d/"));
1137
1138        // duplicates constructed directly are all reported
1139        let dup = ZipFile {
1140            entries: vec![
1141                ZipFileEntry::file("same", b"1".to_vec()),
1142                ZipFileEntry::file("same", b"2".to_vec()),
1143            ],
1144        };
1145        assert_eq!(dup.paths(), vec!["same", "same"]);
1146        // get() returns the *first* match
1147        assert_eq!(dup.get("same").unwrap().data, b"1");
1148        assert!(dup.contains("same"));
1149        // ...and remove() drops every duplicate
1150        let mut dup = dup;
1151        dup.remove("same");
1152        assert!(dup.entries.is_empty());
1153    }
1154
1155    #[test]
1156    fn autotest_filter_by_suffix_edge_cases() {
1157        let zip = ZipFile {
1158            entries: vec![
1159                ZipFileEntry::file("a.txt", b"1".to_vec()),
1160                ZipFileEntry::file("b.TXT", b"2".to_vec()),
1161                ZipFileEntry::file("README", b"3".to_vec()),
1162                ZipFileEntry::file("", b"4".to_vec()),
1163                ZipFileEntry::file("\u{1F600}.json", b"5".to_vec()),
1164                ZipFileEntry::directory("dir.txt"),
1165                ZipFileEntry::directory("sub/"),
1166            ],
1167        };
1168
1169        // empty suffix matches every *non-directory* entry
1170        assert_eq!(zip.filter_by_suffix("").len(), 5);
1171        assert!(zip.filter_by_suffix("").iter().all(|e| !e.is_directory));
1172
1173        // directories are excluded even when their path ends with the suffix
1174        let txt = zip.filter_by_suffix(".txt");
1175        assert_eq!(txt.len(), 1);
1176        assert_eq!(txt[0].path, "a.txt");
1177
1178        // matching is case-sensitive
1179        assert_eq!(zip.filter_by_suffix(".TXT").len(), 1);
1180        assert_eq!(zip.filter_by_suffix(".Txt").len(), 0);
1181
1182        // whole-path suffix matches
1183        assert_eq!(zip.filter_by_suffix("README").len(), 1);
1184
1185        // multibyte suffix must not split a char boundary or panic
1186        assert_eq!(zip.filter_by_suffix("\u{1F600}.json").len(), 1);
1187        assert_eq!(zip.filter_by_suffix("json").len(), 1);
1188
1189        // suffix longer than any path -> empty, no panic
1190        assert!(zip.filter_by_suffix(&"n".repeat(100_000)).is_empty());
1191        // junk suffixes
1192        assert!(zip.filter_by_suffix("\0").is_empty());
1193        assert!(zip.filter_by_suffix("  ").is_empty());
1194    }
1195
1196    // ==================================================================
1197    // parsers: malformed / hostile input
1198    // ==================================================================
1199
1200    #[cfg(feature = "zip")]
1201    #[test]
1202    fn autotest_readers_reject_empty_and_garbage_without_panicking() {
1203        let cfg = ZipReadConfig::default();
1204
1205        let inputs: Vec<Vec<u8>> = vec![
1206            Vec::new(),
1207            b"   ".to_vec(),
1208            b"\t\n\r ".to_vec(),
1209            b"not a zip file at all".to_vec(),
1210            vec![0u8; 22],
1211            vec![0xFF, 0xFE, 0x00],
1212            vec![0xC3, 0x28, 0xA0, 0xA1],           // invalid UTF-8
1213            b"PK".to_vec(),                          // truncated signature
1214            b"PK\x03\x04".to_vec(),                  // local header signature only
1215            b"PK\x05\x06".to_vec(),                  // truncated EOCD
1216            b"0 -0 NaN inf 9223372036854775807".to_vec(),
1217            "\u{1F600}\u{0301}".as_bytes().to_vec(), // multibyte unicode
1218            b"[".repeat(10_000),                     // "deeply nested" junk
1219            b"PK\x05\x06".repeat(5_000),             // many EOCD-ish signatures
1220        ];
1221
1222        for data in inputs {
1223            let listed = ZipFile::list(&data, &cfg);
1224            let loaded = ZipFile::from_bytes(&data, &cfg);
1225            let extracted = zip_extract_all(&data, &cfg);
1226            let contents = zip_list_contents(&data, &cfg);
1227
1228            // the free functions must agree with the inherent methods
1229            assert_eq!(loaded.is_err(), extracted.is_err());
1230            assert_eq!(listed.is_err(), contents.is_err());
1231
1232            match loaded {
1233                Err(e) => {
1234                    // garbage must surface as a *parse* failure, never as a
1235                    // security/limit verdict (UnsafePath / FileTooLarge / ...)
1236                    assert!(
1237                        matches!(
1238                            e,
1239                            ZipReadError::InvalidFormat(_) | ZipReadError::IoError(_)
1240                        ),
1241                        "unexpected error kind for {:?}: {e:?}",
1242                        &data[..data.len().min(8)]
1243                    );
1244                    assert!(!e.to_string().is_empty());
1245                }
1246                // if it *did* parse, it must be a degenerate empty archive
1247                Ok(z) => assert!(z.entries.is_empty()),
1248            }
1249        }
1250
1251        // empty input specifically is a format error, not an I/O error
1252        assert!(matches!(
1253            ZipFile::from_bytes(b"", &cfg),
1254            Err(ZipReadError::InvalidFormat(_))
1255        ));
1256        assert!(matches!(
1257            ZipFile::list(b"", &cfg),
1258            Err(ZipReadError::InvalidFormat(_))
1259        ));
1260    }
1261
1262    #[cfg(feature = "zip")]
1263    #[test]
1264    fn autotest_readers_handle_one_megabyte_of_junk() {
1265        let cfg = ZipReadConfig::default();
1266        // 1 MiB with no valid central directory: must fail fast, not hang or OOM
1267        let junk = vec![b'A'; 1_000_000];
1268        assert!(ZipFile::from_bytes(&junk, &cfg).is_err());
1269        assert!(ZipFile::list(&junk, &cfg).is_err());
1270
1271        // 1 MiB of zeros (a plausible sparse/zeroed file)
1272        let zeros = vec![0u8; 1_000_000];
1273        assert!(ZipFile::from_bytes(&zeros, &cfg).is_err());
1274
1275        // 1 MiB ending in something that looks like an EOCD but isn't consistent
1276        let mut fake = vec![b'B'; 1_000_000];
1277        fake.extend_from_slice(&[0x50, 0x4B, 0x05, 0x06]);
1278        fake.extend_from_slice(&[0xFFu8; 18]);
1279        let res = ZipFile::from_bytes(&fake, &cfg);
1280        assert!(
1281            res.map_or(true, |z| z.entries.is_empty()),
1282            "a bogus EOCD must not yield phantom entries"
1283        );
1284    }
1285
1286    #[cfg(feature = "zip")]
1287    #[test]
1288    fn autotest_minimal_valid_archives_parse_as_empty() {
1289        let cfg = ZipReadConfig::default();
1290
1291        // positive control #1: what this module itself writes for an empty archive
1292        let own = ZipFile::new()
1293            .to_bytes(&ZipWriteConfig::default())
1294            .expect("empty archive must be writable");
1295        let round = ZipFile::from_bytes(&own, &cfg).expect("own empty archive must re-read");
1296        assert!(round.entries.is_empty());
1297        assert!(ZipFile::list(&own, &cfg).unwrap().is_empty());
1298
1299        // an empty archive is also writable with the store() config (no file entries)
1300        assert!(ZipFile::new().to_bytes(&ZipWriteConfig::store()).is_ok());
1301
1302        // positive control #2: the canonical 22-byte EOCD-only archive
1303        let eocd = eocd_only();
1304        assert_eq!(eocd.len(), 22);
1305        if let Ok(z) = ZipFile::from_bytes(&eocd, &cfg) {
1306            assert!(z.entries.is_empty());
1307        }
1308    }
1309
1310    #[cfg(feature = "zip")]
1311    #[test]
1312    fn autotest_truncated_and_bitflipped_archives_never_panic() {
1313        let cfg = ZipReadConfig::default();
1314        let good = build(vec![
1315            ZipFileEntry::file("a.txt", b"hello hello hello hello".to_vec()),
1316            ZipFileEntry::file("b.bin", vec![7u8; 512]),
1317        ]);
1318        assert!(ZipFile::from_bytes(&good, &cfg).is_ok());
1319
1320        // every truncation prefix must be handled (Err or degenerate Ok), never a panic
1321        for cut in [0, 1, 3, 4, 10, good.len() / 4, good.len() / 2, good.len() - 1] {
1322            let _ = ZipFile::from_bytes(&good[..cut], &cfg);
1323            let _ = ZipFile::list(&good[..cut], &cfg);
1324        }
1325
1326        // single-byte corruption anywhere in the stream
1327        for i in (0..good.len()).step_by(7) {
1328            let mut bad = good.clone();
1329            bad[i] ^= 0xFF;
1330            let _ = ZipFile::from_bytes(&bad, &cfg);
1331            let _ = ZipFile::list(&bad, &cfg);
1332        }
1333
1334        // trailing junk appended after the EOCD
1335        let mut trailing = good.clone();
1336        trailing.extend_from_slice(b"garbage;garbage");
1337        let _ = ZipFile::from_bytes(&trailing, &cfg);
1338
1339        // leading junk prepended before the local headers
1340        let mut leading = b"JUNK".to_vec();
1341        leading.extend_from_slice(&good);
1342        let _ = ZipFile::from_bytes(&leading, &cfg);
1343    }
1344
1345    // ==================================================================
1346    // round-trip: encode == decode
1347    // ==================================================================
1348
1349    #[cfg(feature = "zip")]
1350    #[test]
1351    fn autotest_roundtrip_all_byte_values_and_empty_files() {
1352        let all_bytes: Vec<u8> = (0..=255u8).collect();
1353        let entries = vec![
1354            ZipFileEntry::file("bytes.bin", all_bytes.clone()),
1355            ZipFileEntry::file("empty.bin", Vec::new()),
1356            ZipFileEntry::file("one.bin", vec![0u8]),
1357        ];
1358        let bytes = build(entries);
1359        let cfg = ZipReadConfig::default();
1360        let round = ZipFile::from_bytes(&bytes, &cfg).unwrap();
1361
1362        assert_eq!(round.entries.len(), 3);
1363        assert_eq!(round.paths(), vec!["bytes.bin", "empty.bin", "one.bin"]);
1364        assert_eq!(round.get("bytes.bin").unwrap().data, all_bytes);
1365        assert!(round.get("empty.bin").unwrap().data.is_empty());
1366        assert_eq!(round.get("one.bin").unwrap().data, vec![0u8]);
1367        assert!(round.entries.iter().all(|e| !e.is_directory));
1368
1369        // re-encoding the decoded archive yields the same decoded content
1370        let again = round.to_bytes(&ZipWriteConfig::default()).unwrap();
1371        let round2 = ZipFile::from_bytes(&again, &cfg).unwrap();
1372        assert_eq!(round2.paths(), round.paths());
1373        for e in &round.entries {
1374            assert_eq!(round2.get(&e.path).unwrap().data, e.data);
1375        }
1376    }
1377
1378    #[cfg(feature = "zip")]
1379    #[test]
1380    fn autotest_roundtrip_unicode_paths_and_content() {
1381        let paths = [
1382            "\u{1F600}.txt",
1383            "e\u{0301}\u{0301}combining.txt",
1384            "\u{4F60}\u{597D}/\u{4E16}\u{754C}.txt",
1385            "\u{FEFF}bom.txt",
1386            "spaces   and\ttabs.txt",
1387        ];
1388        let entries: Vec<ZipFileEntry> = paths
1389            .iter()
1390            .enumerate()
1391            .map(|(i, p)| ZipFileEntry::file(*p, format!("payload \u{1F9F0} {i}").into_bytes()))
1392            .collect();
1393
1394        let bytes = build(entries);
1395        let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
1396        assert_eq!(round.entries.len(), paths.len());
1397        for (i, p) in paths.iter().enumerate() {
1398            let e = round
1399                .get(p)
1400                .unwrap_or_else(|| panic!("unicode path {p:?} was not preserved"));
1401            assert_eq!(e.data, format!("payload \u{1F9F0} {i}").into_bytes());
1402        }
1403    }
1404
1405    #[cfg(feature = "zip")]
1406    #[test]
1407    fn autotest_roundtrip_deep_paths_and_large_payload() {
1408        // ~4 KiB deeply nested path (2000 components) - must not stack-overflow
1409        let deep = "a/".repeat(2000) + "leaf.txt";
1410        assert!(!deep.contains(".."));
1411        // 100 KiB payload with a non-degenerate byte distribution
1412        let big: Vec<u8> = (0..100_000u32).map(|i| u8::try_from(i % 251).unwrap()).collect();
1413
1414        let bytes = build(vec![
1415            ZipFileEntry::file(deep.clone(), b"leaf".to_vec()),
1416            ZipFileEntry::file("big.bin", big.clone()),
1417        ]);
1418        let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
1419        assert_eq!(round.get(&deep).unwrap().data, b"leaf");
1420        assert_eq!(round.get("big.bin").unwrap().data, big);
1421
1422        // list() reports the true uncompressed size for the large entry
1423        let listed = ZipFile::list(&bytes, &ZipReadConfig::default()).unwrap();
1424        let big_meta = listed.iter().find(|e| e.path == "big.bin").unwrap();
1425        assert_eq!(big_meta.size, 100_000);
1426        assert!(!big_meta.is_directory);
1427    }
1428
1429    #[cfg(feature = "zip")]
1430    #[test]
1431    fn autotest_roundtrip_directory_entries_get_a_trailing_slash() {
1432        let bytes = build(vec![
1433            ZipFileEntry::directory("with_slash/"),
1434            ZipFileEntry::directory("no_slash"),
1435            ZipFileEntry::file("f.txt", b"x".to_vec()),
1436        ]);
1437        let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
1438        assert_eq!(round.entries.len(), 3);
1439
1440        let with = round.get("with_slash/").expect("dir with slash preserved");
1441        assert!(with.is_directory);
1442        assert!(with.data.is_empty());
1443
1444        // NOTE: the underlying writer rewrites "no_slash" -> "no_slash/", so the
1445        // path that comes back is NOT the path that went in. Asserted, not fixed.
1446        assert!(round.get("no_slash").is_none());
1447        let without = round.get("no_slash/").expect("dir without slash was rewritten");
1448        assert!(without.is_directory);
1449
1450        assert!(!round.get("f.txt").unwrap().is_directory);
1451
1452        // list() agrees about directory-ness
1453        let listed = ZipFile::list(&bytes, &ZipReadConfig::default()).unwrap();
1454        assert_eq!(listed.len(), 3);
1455        assert_eq!(listed.iter().filter(|e| e.is_directory).count(), 2);
1456        for d in listed.iter().filter(|e| e.is_directory) {
1457            assert_eq!(d.size, 0);
1458            assert!(d.path.ends_with('/'));
1459        }
1460    }
1461
1462    #[cfg(feature = "zip")]
1463    #[test]
1464    fn autotest_roundtrip_survives_config_extremes() {
1465        let entries = || vec![ZipFileEntry::file("f.bin", vec![9u8; 3000])];
1466
1467        // every deflate level that the writer accepts
1468        for level in 1..=9u8 {
1469            let cfg = ZipWriteConfig::deflate(level);
1470            let bytes = zip_create(entries(), &cfg)
1471                .unwrap_or_else(|e| panic!("deflate({level}) failed: {e}"));
1472            let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
1473            assert_eq!(round.get("f.bin").unwrap().data, vec![9u8; 3000]);
1474        }
1475        // deflate() saturates, so 10..=255 all behave like 9
1476        for level in [10u8, 100, u8::MAX] {
1477            let bytes = zip_create(entries(), &ZipWriteConfig::deflate(level)).unwrap();
1478            let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
1479            assert_eq!(round.get("f.bin").unwrap().data.len(), 3000);
1480        }
1481
1482        // u32::MAX permissions must not overflow or corrupt the archive
1483        let mut cfg = ZipWriteConfig {
1484            unix_permissions: u32::MAX,
1485            ..Default::default()
1486        };
1487        let bytes = zip_create(entries(), &cfg).unwrap();
1488        assert_eq!(
1489            ZipFile::from_bytes(&bytes, &ZipReadConfig::default())
1490                .unwrap()
1491                .get("f.bin")
1492                .unwrap()
1493                .data
1494                .len(),
1495            3000
1496        );
1497        cfg.unix_permissions = 0;
1498        assert!(zip_create(entries(), &cfg).is_ok());
1499
1500        // a unicode archive comment keeps the EOCD findable
1501        let cfg = ZipWriteConfig::default().with_comment("\u{1F5DC}\u{FE0F} t\u{E9}st comment");
1502        let bytes = zip_create(entries(), &cfg).unwrap();
1503        let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
1504        assert_eq!(round.entries.len(), 1);
1505
1506        // an over-long comment (past the u16 EOCD length field) must not panic
1507        let cfg = ZipWriteConfig::default().with_comment("c".repeat(70_000));
1508        let _ = zip_create(entries(), &cfg);
1509    }
1510
1511    #[cfg(feature = "zip")]
1512    #[test]
1513    fn autotest_convenience_functions_agree_with_methods() {
1514        let files = vec![
1515            ("a.txt".to_string(), b"AAA".to_vec()),
1516            ("dir/b.bin".to_string(), vec![0u8, 255, 128]),
1517        ];
1518        let cfg = ZipWriteConfig::default();
1519        let via_files = zip_create_from_files(files.clone(), &cfg).unwrap();
1520        let via_entries = zip_create(
1521            files
1522                .iter()
1523                .map(|(p, d)| ZipFileEntry::file(p.clone(), d.clone()))
1524                .collect(),
1525            &cfg,
1526        )
1527        .unwrap();
1528        let via_method = ZipFile {
1529            entries: files
1530                .iter()
1531                .map(|(p, d)| ZipFileEntry::file(p.clone(), d.clone()))
1532                .collect(),
1533        }
1534        .to_bytes(&cfg)
1535        .unwrap();
1536
1537        let rcfg = ZipReadConfig::default();
1538        for bytes in [&via_files, &via_entries, &via_method] {
1539            let extracted = zip_extract_all(bytes, &rcfg).unwrap();
1540            let loaded = ZipFile::from_bytes(bytes, &rcfg).unwrap();
1541            assert_eq!(extracted.len(), 2);
1542            assert_eq!(loaded.entries.len(), 2);
1543            for (i, (p, d)) in files.iter().enumerate() {
1544                assert_eq!(&extracted[i].path, p);
1545                assert_eq!(&extracted[i].data, d);
1546                assert_eq!(&loaded.entries[i].path, p);
1547            }
1548
1549            // zip_list_contents == ZipFile::list, and paths/sizes match the data
1550            let listed = zip_list_contents(bytes, &rcfg).unwrap();
1551            let listed2 = ZipFile::list(bytes, &rcfg).unwrap();
1552            assert_eq!(listed.len(), listed2.len());
1553            for (a, b) in listed.iter().zip(listed2.iter()) {
1554                assert_eq!(a.path, b.path);
1555                assert_eq!(a.size, b.size);
1556                assert_eq!(a.compressed_size, b.compressed_size);
1557                assert_eq!(a.crc32, b.crc32);
1558                assert_eq!(a.is_directory, b.is_directory);
1559            }
1560            for (meta, (p, d)) in listed.iter().zip(files.iter()) {
1561                assert_eq!(&meta.path, p);
1562                assert_eq!(meta.size, d.len() as u64);
1563                assert!(!meta.is_directory);
1564            }
1565        }
1566
1567        // empty input list -> valid empty archive
1568        let empty = zip_create_from_files(Vec::new(), &cfg).unwrap();
1569        assert!(zip_extract_all(&empty, &rcfg).unwrap().is_empty());
1570        assert!(zip_list_contents(&empty, &rcfg).unwrap().is_empty());
1571    }
1572
1573    // ==================================================================
1574    // security checks: path traversal + size limits
1575    // ==================================================================
1576
1577    #[cfg(feature = "zip")]
1578    #[test]
1579    fn autotest_path_traversal_check_is_a_plain_substring_test() {
1580        // "a..b.txt" is NOT a traversal, but the check is `path.contains("..")`,
1581        // so it is rejected anyway. Asserting the real (over-strict) behaviour.
1582        let bytes = build(vec![
1583            ZipFileEntry::file("a..b.txt", b"harmless".to_vec()),
1584            ZipFileEntry::file("ok.txt", b"ok".to_vec()),
1585        ]);
1586
1587        let strict = ZipReadConfig::default();
1588        match ZipFile::from_bytes(&bytes, &strict) {
1589            Err(ZipReadError::UnsafePath(p)) => assert_eq!(p, "a..b.txt"),
1590            other => panic!("expected UnsafePath, got {other:?}"),
1591        }
1592        match ZipFile::list(&bytes, &strict) {
1593            Err(ZipReadError::UnsafePath(p)) => assert_eq!(p, "a..b.txt"),
1594            other => panic!("expected UnsafePath from list(), got {other:?}"),
1595        }
1596
1597        // ...and the whole archive is rejected, not just the offending entry
1598        let loose = ZipReadConfig::new().with_allow_path_traversal(true);
1599        let round = ZipFile::from_bytes(&bytes, &loose).unwrap();
1600        assert_eq!(round.entries.len(), 2);
1601        assert_eq!(round.get("a..b.txt").unwrap().data, b"harmless");
1602        assert_eq!(ZipFile::list(&bytes, &loose).unwrap().len(), 2);
1603
1604        // a real traversal path is rejected under the strict config too
1605        let evil = build(vec![ZipFileEntry::file("../../etc/passwd", b"x".to_vec())]);
1606        assert!(matches!(
1607            ZipFile::from_bytes(&evil, &strict),
1608            Err(ZipReadError::UnsafePath(_))
1609        ));
1610        assert!(ZipFile::from_bytes(&evil, &loose).is_ok());
1611
1612        // a path with a single dot is fine
1613        let dotted = build(vec![ZipFileEntry::file("./a.txt", b"x".to_vec())]);
1614        assert!(ZipFile::from_bytes(&dotted, &strict).is_ok());
1615    }
1616
1617    #[cfg(feature = "zip")]
1618    #[test]
1619    fn autotest_max_file_size_is_enforced_by_from_bytes_only() {
1620        let payload = vec![b'q'; 1000];
1621        let bytes = build(vec![ZipFileEntry::file("big.bin", payload.clone())]);
1622
1623        // 0 means unlimited
1624        let unlimited = ZipReadConfig::new().with_max_file_size(0);
1625        assert_eq!(
1626            ZipFile::from_bytes(&bytes, &unlimited).unwrap().entries[0].data,
1627            payload
1628        );
1629
1630        // exactly at the limit is allowed; one below is not
1631        let at = ZipReadConfig::new().with_max_file_size(1000);
1632        assert!(ZipFile::from_bytes(&bytes, &at).is_ok());
1633        let under = ZipReadConfig::new().with_max_file_size(999);
1634        match ZipFile::from_bytes(&bytes, &under) {
1635            Err(ZipReadError::FileTooLarge { path, size, max_size }) => {
1636                assert_eq!(path, "big.bin");
1637                assert_eq!(size, 1000);
1638                assert_eq!(max_size, 999);
1639            }
1640            other => panic!("expected FileTooLarge, got {other:?}"),
1641        }
1642        assert!(ZipFile::from_bytes(&bytes, &ZipReadConfig::new().with_max_file_size(1)).is_err());
1643        assert!(zip_extract_all(&bytes, &under).is_err());
1644
1645        // NOTE: list() deliberately ignores max_file_size (metadata only), so a
1646        // 1-byte limit still lists a 1000-byte entry. Documented, not enforced.
1647        let listed = ZipFile::list(&bytes, &under).unwrap();
1648        assert_eq!(listed.len(), 1);
1649        assert_eq!(listed[0].size, 1000);
1650        assert!(listed[0].compressed_size > 0);
1651        assert_eq!(zip_list_contents(&bytes, &under).unwrap().len(), 1);
1652    }
1653
1654    #[cfg(feature = "zip")]
1655    #[test]
1656    fn autotest_get_single_file_lookup_semantics() {
1657        let bytes = build(vec![
1658            ZipFileEntry::file("a.txt", b"AAA".to_vec()),
1659            ZipFileEntry::directory("sub/"),
1660        ]);
1661        let cfg = ZipReadConfig::default();
1662        let meta = ZipFile::list(&bytes, &cfg).unwrap();
1663
1664        // positive control: every listed entry is retrievable and matches from_bytes
1665        let loaded = ZipFile::from_bytes(&bytes, &cfg).unwrap();
1666        for m in &meta {
1667            let got = ZipFile::get_single_file(&bytes, m, &cfg).unwrap();
1668            assert_eq!(got.as_deref(), Some(loaded.get(&m.path).unwrap().data.as_slice()));
1669        }
1670
1671        // a directory yields an empty payload, not an error
1672        let dir = meta.iter().find(|m| m.is_directory).unwrap();
1673        assert_eq!(ZipFile::get_single_file(&bytes, dir, &cfg).unwrap(), Some(Vec::new()));
1674
1675        // missing / junk paths return Ok(None), never Err and never a panic
1676        for p in nasty_paths() {
1677            let entry = ZipPathEntry {
1678                path: p.clone(),
1679                is_directory: false,
1680                size: 0,
1681                compressed_size: 0,
1682                crc32: 0,
1683            };
1684            assert_eq!(
1685                ZipFile::get_single_file(&bytes, &entry, &cfg).unwrap(),
1686                None,
1687                "expected None for {p:?}"
1688            );
1689        }
1690
1691        // malformed archive data surfaces as InvalidFormat
1692        let entry = ZipPathEntry {
1693            path: "a.txt".into(),
1694            is_directory: false,
1695            size: 3,
1696            compressed_size: 3,
1697            crc32: 0,
1698        };
1699        for junk in [b"".as_slice(), b"   ", b"nope", &[0xFF, 0xFE, 0x00]] {
1700            assert!(matches!(
1701                ZipFile::get_single_file(junk, &entry, &cfg),
1702                Err(ZipReadError::InvalidFormat(_))
1703            ));
1704        }
1705    }
1706
1707    #[cfg(feature = "zip")]
1708    #[test]
1709    fn autotest_get_single_file_size_check_runs_before_parsing() {
1710        // The limit check is done on the caller-supplied entry, before the archive
1711        // is even opened - so garbage bytes still yield FileTooLarge.
1712        let cfg = ZipReadConfig::new().with_max_file_size(10);
1713        let entry = ZipPathEntry {
1714            path: "x".into(),
1715            is_directory: false,
1716            size: 11,
1717            compressed_size: 0,
1718            crc32: 0,
1719        };
1720        match ZipFile::get_single_file(b"total garbage", &entry, &cfg) {
1721            Err(ZipReadError::FileTooLarge { path, size, max_size }) => {
1722                assert_eq!(path, "x");
1723                assert_eq!(size, 11);
1724                assert_eq!(max_size, 10);
1725            }
1726            other => panic!("expected FileTooLarge before parsing, got {other:?}"),
1727        }
1728
1729        // boundary: size == max is allowed through to the parser
1730        let at_limit = ZipPathEntry { size: 10, ..entry.clone() };
1731        assert!(matches!(
1732            ZipFile::get_single_file(b"total garbage", &at_limit, &cfg),
1733            Err(ZipReadError::InvalidFormat(_))
1734        ));
1735
1736        // max_file_size == 0 disables the check entirely, even for u64::MAX sizes
1737        let unlimited = ZipReadConfig::default();
1738        let huge = ZipPathEntry { size: u64::MAX, ..entry };
1739        assert!(matches!(
1740            ZipFile::get_single_file(b"total garbage", &huge, &unlimited),
1741            Err(ZipReadError::InvalidFormat(_))
1742        ));
1743    }
1744
1745    #[cfg(feature = "zip")]
1746    #[test]
1747    fn autotest_get_single_file_trusts_the_callers_metadata() {
1748        // BUG (documented, not fixed): get_single_file checks `entry.size` -- which
1749        // the caller (or a hostile archive header) supplies -- instead of the real
1750        // entry size, so a lying entry walks straight past max_file_size.
1751        let payload = vec![b'z'; 5000];
1752        let bytes = build(vec![ZipFileEntry::file("big.bin", payload.clone())]);
1753
1754        let capped = ZipReadConfig::new().with_max_file_size(10);
1755        let liar = ZipPathEntry {
1756            path: "big.bin".into(),
1757            is_directory: false,
1758            size: 0, // lie: real size is 5000
1759            compressed_size: 0,
1760            crc32: 0,
1761        };
1762        let got = ZipFile::get_single_file(&bytes, &liar, &capped).unwrap();
1763        assert_eq!(
1764            got,
1765            Some(payload),
1766            "the 10-byte cap was bypassed by a lying entry.size"
1767        );
1768        // ...while from_bytes with the same config correctly refuses:
1769        assert!(matches!(
1770            ZipFile::from_bytes(&bytes, &capped),
1771            Err(ZipReadError::FileTooLarge { .. })
1772        ));
1773
1774        // BUG (documented, not fixed): get_single_file also performs no path
1775        // traversal check at all, unlike list()/from_bytes().
1776        let bytes = build(vec![ZipFileEntry::file("../evil.txt", b"pwned".to_vec())]);
1777        let strict = ZipReadConfig::default();
1778        assert!(matches!(
1779            ZipFile::from_bytes(&bytes, &strict),
1780            Err(ZipReadError::UnsafePath(_))
1781        ));
1782        let entry = ZipPathEntry {
1783            path: "../evil.txt".into(),
1784            is_directory: false,
1785            size: 5,
1786            compressed_size: 5,
1787            crc32: 0,
1788        };
1789        assert_eq!(
1790            ZipFile::get_single_file(&bytes, &entry, &strict).unwrap(),
1791            Some(b"pwned".to_vec()),
1792            "get_single_file has no UnsafePath guard"
1793        );
1794    }
1795
1796    /// BUG (documented, not fixed): `get_single_file` does
1797    /// `Vec::with_capacity(usize::try_from(entry.size).unwrap_or(0))` on the
1798    /// *declared* size. A hostile archive header (surfaced verbatim by `list()`)
1799    /// declaring `u64::MAX` therefore aborts the process with "capacity overflow"
1800    /// before a single byte is read. Should be a bounded/incremental read.
1801    #[cfg(all(feature = "zip", target_pointer_width = "64"))]
1802    #[test]
1803    #[should_panic]
1804    fn autotest_bug_get_single_file_capacity_overflow_on_declared_size() {
1805        let bytes = build(vec![ZipFileEntry::file("a.txt", b"AAA".to_vec())]);
1806        let entry = ZipPathEntry {
1807            path: "a.txt".into(),
1808            is_directory: false,
1809            size: u64::MAX, // max_file_size == 0 means "unlimited", so this passes the check
1810            compressed_size: 3,
1811            crc32: 0,
1812        };
1813        let _ = ZipFile::get_single_file(&bytes, &entry, &ZipReadConfig::default());
1814    }
1815
1816    // ==================================================================
1817    // writer: configurations that cannot produce an archive
1818    // ==================================================================
1819
1820    /// BUG (documented, not fixed): `to_bytes` always passes
1821    /// `compression_level(Some(..))`, but the backend rejects *any* explicit level
1822    /// for `Stored`. `ZipWriteConfig::store()` therefore cannot write a single
1823    /// file entry -- uncompressed archives are unreachable through this API.
1824    #[cfg(feature = "zip")]
1825    #[test]
1826    fn autotest_bug_store_config_cannot_write_file_entries() {
1827        let cfg = ZipWriteConfig::store();
1828        let err = zip_create(vec![ZipFileEntry::file("a.txt", b"A".to_vec())], &cfg)
1829            .expect_err("store() unexpectedly produced an archive");
1830        assert!(
1831            err.to_string().contains("compression level"),
1832            "unexpected error for store(): {err}"
1833        );
1834        assert!(matches!(err, ZipWriteError::IoError(_)));
1835
1836        // ...but an archive with no file entries still succeeds, which makes the
1837        // failure look intermittent to callers.
1838        assert!(ZipFile::new().to_bytes(&cfg).is_ok());
1839
1840        // any compression_method != 0 maps to Deflate and works
1841        let mut deflate_ish = ZipWriteConfig::store();
1842        deflate_ish.compression_method = 2;
1843        deflate_ish.compression_level = 6;
1844        assert!(zip_create(vec![ZipFileEntry::file("a.txt", b"A".to_vec())], &deflate_ish).is_ok());
1845    }
1846
1847    /// BUG (documented, not fixed): `ZipWriteConfig::deflate(0)` is accepted by the
1848    /// builder (`0.min(9) == 0`) but the deflate backend's valid level range starts
1849    /// at 1, so the resulting config can never write a file.
1850    #[cfg(feature = "zip")]
1851    #[test]
1852    fn autotest_bug_deflate_level_zero_is_unwritable() {
1853        let cfg = ZipWriteConfig::deflate(0);
1854        assert_eq!(cfg.compression_level, 0, "builder accepted level 0");
1855        let err = zip_create(vec![ZipFileEntry::file("a.txt", b"A".to_vec())], &cfg)
1856            .expect_err("deflate(0) unexpectedly produced an archive");
1857        assert!(
1858            err.to_string().contains("compression level"),
1859            "unexpected error for deflate(0): {err}"
1860        );
1861        // level 1 is the first level that actually works
1862        assert!(zip_create(vec![ZipFileEntry::file("a.txt", b"A".to_vec())], &ZipWriteConfig::deflate(1)).is_ok());
1863    }
1864
1865    #[cfg(feature = "zip")]
1866    #[test]
1867    fn autotest_duplicate_paths_make_the_archive_unwritable() {
1868        // add_file() de-duplicates, but ZipFile.entries is a public field and
1869        // zip_create() takes an arbitrary Vec, so duplicates reach the writer.
1870        let cfg = ZipWriteConfig::default();
1871        let err = zip_create(
1872            vec![
1873                ZipFileEntry::file("dup.txt", b"1".to_vec()),
1874                ZipFileEntry::file("dup.txt", b"2".to_vec()),
1875            ],
1876            &cfg,
1877        )
1878        .expect_err("duplicate paths unexpectedly accepted");
1879        assert!(matches!(err, ZipWriteError::IoError(_)));
1880        assert!(!err.to_string().is_empty());
1881
1882        // zip_create_from_files has the same hazard
1883        assert!(zip_create_from_files(
1884            vec![
1885                ("d".to_string(), b"1".to_vec()),
1886                ("d".to_string(), b"2".to_vec()),
1887            ],
1888            &cfg
1889        )
1890        .is_err());
1891
1892        // going through add_file() is safe because it de-duplicates first
1893        let mut zip = ZipFile::new();
1894        zip.add_file("dup.txt", b"1".to_vec());
1895        zip.add_file("dup.txt", b"2".to_vec());
1896        let bytes = zip.to_bytes(&cfg).unwrap();
1897        assert_eq!(
1898            ZipFile::from_bytes(&bytes, &ZipReadConfig::default())
1899                .unwrap()
1900                .get("dup.txt")
1901                .unwrap()
1902                .data,
1903            b"2"
1904        );
1905    }
1906
1907    #[cfg(feature = "zip")]
1908    #[test]
1909    fn autotest_to_bytes_with_hostile_paths_never_panics() {
1910        let cfg = ZipWriteConfig::default();
1911        let loose = ZipReadConfig::new().with_allow_path_traversal(true);
1912        // BUG (documented, NOT exercised here): nothing validates path length, and
1913        // the ZIP file-name field is a u16. A path of 65_536+ bytes panics inside
1914        // the writer (`file_name_raw.len().try_into().unwrap()`) instead of
1915        // returning `ZipWriteError::InvalidPath`. It cannot be asserted with
1916        // #[should_panic] because ZipWriter::drop re-panics on the same unwrap
1917        // while unwinding, which aborts the process. Hence the <60_000 filter.
1918        // Each path is written into its own archive so one rejection does not mask
1919        // the others; the contract under test is "Ok or Err, never a panic".
1920        for p in nasty_paths().into_iter().filter(|p| p.len() < 60_000) {
1921            if let Ok(bytes) = zip_create(vec![ZipFileEntry::file(p.clone(), b"x".to_vec())], &cfg)
1922            {
1923                // if it encoded, it must decode back without panicking
1924                let _ = ZipFile::from_bytes(&bytes, &loose);
1925            }
1926            if let Ok(bytes) = zip_create(vec![ZipFileEntry::directory(p)], &cfg) {
1927                let _ = ZipFile::from_bytes(&bytes, &loose);
1928            }
1929        }
1930
1931        // a 60_000-byte path is under the u16 field limit and must round-trip
1932        let long = "L".repeat(60_000);
1933        let bytes = zip_create(vec![ZipFileEntry::file(long.clone(), b"x".to_vec())], &cfg)
1934            .expect("60_000-byte path must be writable");
1935        assert_eq!(
1936            ZipFile::from_bytes(&bytes, &loose).unwrap().get(&long).unwrap().data,
1937            b"x"
1938        );
1939    }
1940
1941    // ==================================================================
1942    // file-system entry points
1943    // ==================================================================
1944
1945    #[cfg(all(feature = "zip", feature = "std"))]
1946    #[test]
1947    fn autotest_from_file_missing_path_is_io_error() {
1948        let cfg = ZipReadConfig::default();
1949        for p in [
1950            "/nonexistent_dir_azul_autotest_zip/sub/archive.zip",
1951            "",
1952            "/nonexistent_dir_azul_autotest_zip/\u{1F600}.zip",
1953        ] {
1954            match ZipFile::from_file(std::path::Path::new(p), &cfg) {
1955                Err(ZipReadError::IoError(msg)) => assert!(!msg.is_empty()),
1956                other => panic!("expected IoError for {p:?}, got {other:?}"),
1957            }
1958        }
1959
1960        // a directory is not a readable archive either
1961        let tmp = std::env::temp_dir();
1962        assert!(ZipFile::from_file(&tmp, &cfg).is_err());
1963    }
1964
1965    #[cfg(all(feature = "zip", feature = "std"))]
1966    #[test]
1967    fn autotest_to_file_unwritable_path_is_io_error() {
1968        let mut zip = ZipFile::new();
1969        zip.add_file("a.txt", b"A".to_vec());
1970        let cfg = ZipWriteConfig::default();
1971        match zip.to_file(
1972            std::path::Path::new("/nonexistent_dir_azul_autotest_zip/sub/out.zip"),
1973            &cfg,
1974        ) {
1975            Err(ZipWriteError::IoError(msg)) => assert!(!msg.is_empty()),
1976            other => panic!("expected IoError, got {other:?}"),
1977        }
1978
1979        // a write-config failure is reported before the filesystem is touched
1980        let store = ZipWriteConfig::store();
1981        assert!(zip.to_file(std::path::Path::new("/nonexistent_dir_azul_autotest_zip/x.zip"), &store).is_err());
1982    }
1983
1984    #[cfg(all(feature = "zip", feature = "std"))]
1985    #[test]
1986    fn autotest_file_roundtrip_via_temp_dir() {
1987        let mut zip = ZipFile::new();
1988        zip.add_file("a.txt", b"AAA".to_vec());
1989        zip.add_file("\u{1F600}/b.bin", vec![0u8, 255, 128]);
1990        zip.add_directory("d/");
1991
1992        let path = std::env::temp_dir().join(format!(
1993            "azul_autotest_zip_roundtrip_{}.zip",
1994            std::process::id()
1995        ));
1996        let _ = std::fs::remove_file(&path);
1997
1998        match zip.to_file(&path, &ZipWriteConfig::default()) {
1999            Ok(()) => {
2000                let round = ZipFile::from_file(&path, &ZipReadConfig::default())
2001                    .expect("archive written by to_file must be readable");
2002                assert_eq!(round.entries.len(), 3);
2003                assert_eq!(round.get("a.txt").unwrap().data, b"AAA");
2004                assert_eq!(round.get("\u{1F600}/b.bin").unwrap().data, vec![0u8, 255, 128]);
2005                assert!(round.get("d/").unwrap().is_directory);
2006                // to_file and to_bytes must produce identical content
2007                let in_memory = zip.to_bytes(&ZipWriteConfig::default()).unwrap();
2008                let on_disk = std::fs::read(&path).unwrap();
2009                assert_eq!(in_memory.len(), on_disk.len());
2010                let _ = std::fs::remove_file(&path);
2011            }
2012            Err(ZipWriteError::IoError(_)) => {
2013                // temp dir not writable in this environment - nothing to assert
2014            }
2015            Err(other) => panic!("unexpected write error: {other:?}"),
2016        }
2017    }
2018}