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(Copy, 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
20
    #[must_use] pub fn new() -> Self {
31
20
        Self::default()
32
20
    }
33
    
34
18
    #[must_use] pub const fn with_max_file_size(mut self, max_size: u64) -> Self {
35
18
        self.max_file_size = max_size;
36
18
        self
37
18
    }
38
    
39
6
    #[must_use] pub const fn with_allow_path_traversal(mut self, allow: bool) -> Self {
40
6
        self.allow_path_traversal = allow;
41
6
        self
42
6
    }
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
316
    fn default() -> Self {
61
316
        Self {
62
316
            compression_method: 1, // Deflate
63
316
            compression_level: 6,  // Default compression
64
316
            unix_permissions: 0o644,
65
316
            comment: String::new(),
66
316
        }
67
316
    }
68
}
69

            
70
impl ZipWriteConfig {
71
8
    #[must_use] pub fn new() -> Self {
72
8
        Self::default()
73
8
    }
74
    
75
6
    #[must_use] pub fn store() -> Self {
76
6
        Self {
77
6
            compression_method: 0,
78
6
            compression_level: 0,
79
6
            ..Default::default()
80
6
        }
81
6
    }
82
    
83
275
    #[must_use] pub fn deflate(level: u8) -> Self {
84
275
        Self {
85
275
            compression_method: 1,
86
275
            compression_level: level.min(9),
87
275
            ..Default::default()
88
275
        }
89
275
    }
90
    
91
    #[must_use]
92
11
    pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
93
11
        self.comment = comment.into();
94
11
        self
95
11
    }
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)]
105
pub 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`
119
pub type ZipPathEntryVec = Vec<ZipPathEntry>;
120

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

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

            
153
/// Vec of `ZipFileEntry`  
154
pub 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)]
163
pub 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

            
178
impl fmt::Display for ZipReadError {
179
36
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180
36
        match self {
181
16
            Self::InvalidFormat(msg) => write!(f, "Invalid ZIP format: {msg}"),
182
2
            Self::FileNotFound(path) => write!(f, "File not found: {path}"),
183
2
            Self::IoError(msg) => write!(f, "I/O error: {msg}"),
184
7
            Self::UnsafePath(path) => write!(f, "Unsafe path: {path}"),
185
2
            Self::EncryptedFile(path) => write!(f, "Encrypted file: {path}"),
186
7
            Self::FileTooLarge { path, size, max_size } => {
187
7
                write!(f, "File too large: {path} ({size} > {max_size})")
188
            }
189
        }
190
36
    }
191
}
192

            
193
#[cfg(feature = "std")]
194
impl std::error::Error for ZipReadError {}
195

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

            
208
impl fmt::Display for ZipWriteError {
209
11
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210
11
        match self {
211
6
            Self::IoError(msg) => write!(f, "I/O error: {msg}"),
212
2
            Self::InvalidPath(path) => write!(f, "Invalid path: {path}"),
213
3
            Self::CompressionError(msg) => write!(f, "Compression error: {msg}"),
214
        }
215
11
    }
216
}
217

            
218
#[cfg(feature = "std")]
219
impl 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)]
228
pub struct ZipFile {
229
    /// The entries in the archive
230
    pub entries: ZipFileEntryVec,
231
}
232

            
233
impl ZipFile {
234
    /// Create a new empty ZIP archive
235
15
    #[must_use] pub const fn new() -> Self {
236
15
        Self {
237
15
            entries: Vec::new(),
238
15
        }
239
15
    }
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
94
    pub fn list(data: &[u8], config: &ZipReadConfig) -> Result<ZipPathEntryVec, ZipReadError> {
254
        use std::io::Cursor;
255
        
256
94
        let cursor = Cursor::new(data);
257
94
        let mut archive = zip::ZipArchive::new(cursor)
258
94
            .map_err(|e| ZipReadError::InvalidFormat(e.to_string()))?;
259
        
260
37
        let mut entries = Vec::new();
261
        
262
68
        for i in 0..archive.len() {
263
68
            let file = archive.by_index(i)
264
68
                .map_err(|e| ZipReadError::IoError(e.to_string()))?;
265
            
266
68
            let path = file.name().to_string();
267
            
268
            // Security check
269
68
            if !config.allow_path_traversal && path.contains("..") {
270
1
                return Err(ZipReadError::UnsafePath(path));
271
67
            }
272
            
273
67
            entries.push(ZipPathEntry {
274
67
                path,
275
67
                is_directory: file.is_dir(),
276
67
                size: file.size(),
277
67
                compressed_size: file.compressed_size(),
278
67
                crc32: file.crc32(),
279
67
            });
280
        }
281
        
282
36
        Ok(entries)
283
94
    }
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
39
    pub fn get_single_file(
299
39
        data: &[u8], 
300
39
        entry: &ZipPathEntry,
301
39
        config: &ZipReadConfig,
302
39
    ) -> Result<Option<Vec<u8>>, ZipReadError> {
303
        use std::io::{Cursor, Read};
304
        
305
        // Size check
306
39
        if config.max_file_size > 0 && entry.size > config.max_file_size {
307
1
            return Err(ZipReadError::FileTooLarge {
308
1
                path: entry.path.clone(),
309
1
                size: entry.size,
310
1
                max_size: config.max_file_size,
311
1
            });
312
38
        }
313
        
314
38
        let cursor = Cursor::new(data);
315
38
        let mut archive = zip::ZipArchive::new(cursor)
316
38
            .map_err(|e| ZipReadError::InvalidFormat(e.to_string()))?;
317
        
318
32
        let mut file = match archive.by_name(&entry.path) {
319
6
            Ok(f) => f,
320
26
            Err(zip::result::ZipError::FileNotFound) => return Ok(None),
321
            Err(e) => return Err(ZipReadError::IoError(e.to_string())),
322
        };
323
        
324
6
        if file.is_dir() {
325
2
            return Ok(Some(Vec::new()));
326
4
        }
327
        
328
4
        let mut contents = Vec::with_capacity(usize::try_from(entry.size).unwrap_or(0));
329
4
        file.read_to_end(&mut contents)
330
4
            .map_err(|e| ZipReadError::IoError(e.to_string()))?;
331
        
332
4
        Ok(Some(contents))
333
39
    }
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
170
    pub fn from_bytes(data: &[u8], config: &ZipReadConfig) -> Result<Self, ZipReadError> {
345
        use std::io::{Cursor, Read};
346

            
347
170
        let cursor = Cursor::new(data);
348
170
        let mut archive = zip::ZipArchive::new(cursor)
349
170
            .map_err(|e| ZipReadError::InvalidFormat(e.to_string()))?;
350
        
351
120
        let mut entries = Vec::new();
352
        
353
161
        for i in 0..archive.len() {
354
161
            let mut file = archive.by_index(i)
355
161
                .map_err(|e| ZipReadError::IoError(e.to_string()))?;
356
            
357
161
            let path = file.name().to_string();
358
            
359
            // Security check
360
161
            if !config.allow_path_traversal && path.contains("..") {
361
3
                return Err(ZipReadError::UnsafePath(path));
362
158
            }
363
            
364
            // Size check
365
158
            if config.max_file_size > 0 && file.size() > config.max_file_size {
366
4
                return Err(ZipReadError::FileTooLarge {
367
4
                    path,
368
4
                    size: file.size(),
369
4
                    max_size: config.max_file_size,
370
4
                });
371
154
            }
372
            
373
154
            let is_directory = file.is_dir();
374
154
            let mut file_data = Vec::new();
375
            
376
154
            if !is_directory {
377
124
                file.read_to_end(&mut file_data)
378
124
                    .map_err(|e| ZipReadError::IoError(e.to_string()))?;
379
30
            }
380
            
381
149
            entries.push(ZipFileEntry {
382
149
                path,
383
149
                data: file_data,
384
149
                is_directory,
385
149
            });
386
        }
387
        
388
108
        Ok(Self { entries })
389
170
    }
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
5
    pub fn from_file(path: &Path, config: &ZipReadConfig) -> Result<Self, ZipReadError> {
397
5
        let data = std::fs::read(path)
398
5
            .map_err(|e| ZipReadError::IoError(e.to_string()))?;
399
1
        Self::from_bytes(&data, config)
400
5
    }
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
100
    pub fn to_bytes(&self, config: &ZipWriteConfig) -> Result<Vec<u8>, ZipWriteError> {
411
        use std::io::{Cursor, Write};
412
        use zip::write::SimpleFileOptions;
413
        
414
100
        let buffer = Vec::new();
415
100
        let cursor = Cursor::new(buffer);
416
100
        let mut writer = zip::ZipWriter::new(cursor);
417
        
418
        // Set archive comment
419
100
        if !config.comment.is_empty() {
420
2
            writer.set_comment(config.comment.clone());
421
98
        }
422
        
423
100
        let compression = match config.compression_method {
424
4
            0 => zip::CompressionMethod::Stored,
425
96
            _ => zip::CompressionMethod::Deflated,
426
        };
427
        
428
100
        let options = SimpleFileOptions::default()
429
100
            .compression_method(compression)
430
100
            .compression_level(Some(i64::from(config.compression_level)))
431
100
            .unix_permissions(config.unix_permissions);
432
        
433
215
        for entry in &self.entries {
434
120
            if entry.is_directory {
435
30
                writer.add_directory(&entry.path, options)
436
30
                    .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
437
            } else {
438
90
                writer.start_file(&entry.path, options)
439
90
                    .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
440
85
                writer.write_all(&entry.data)
441
85
                    .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
442
            }
443
        }
444
        
445
95
        let result = writer.finish()
446
95
            .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
447
        
448
94
        Ok(result.into_inner())
449
100
    }
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
3
    pub fn to_file(&self, path: &Path, config: &ZipWriteConfig) -> Result<(), ZipWriteError> {
457
3
        let data = self.to_bytes(config)?;
458
2
        std::fs::write(path, data)
459
2
            .map_err(|e| ZipWriteError::IoError(e.to_string()))?;
460
1
        Ok(())
461
3
    }
462
    
463
    // ========================================================================
464
    // Convenience methods for modifying the archive
465
    // ========================================================================
466
    
467
    /// Add a file entry (consumes the data, no clone)
468
192
    pub fn add_file(&mut self, path: impl Into<String>, data: Vec<u8>) {
469
192
        let path = path.into();
470
        // Remove existing entry with same path
471
1759
        self.entries.retain(|e| e.path != path);
472
192
        self.entries.push(ZipFileEntry::file(path, data));
473
192
    }
474
    
475
    /// Add a directory entry
476
5
    pub fn add_directory(&mut self, path: impl Into<String>) {
477
5
        let path = path.into();
478
56
        self.entries.retain(|e| e.path != path);
479
5
        self.entries.push(ZipFileEntry::directory(path));
480
5
    }
481
    
482
    /// Remove an entry by path
483
35
    pub fn remove(&mut self, path: &str) {
484
360
        self.entries.retain(|e| e.path != path);
485
35
    }
486
    
487
    /// Get an entry by path
488
138
    #[must_use] pub fn get(&self, path: &str) -> Option<&ZipFileEntry> {
489
540
        self.entries.iter().find(|e| e.path == path)
490
138
    }
491
    
492
    /// Check if archive contains a path
493
126
    #[must_use] pub fn contains(&self, path: &str) -> bool {
494
804
        self.entries.iter().any(|e| e.path == path)
495
126
    }
496
    
497
    /// Get list of all paths
498
8
    #[must_use] pub fn paths(&self) -> Vec<&str> {
499
64
        self.entries.iter().map(|e| e.path.as_str()).collect()
500
8
    }
501
    
502
    /// Filter entries by suffix (e.g., ".fluent", ".json")
503
13
    #[must_use] pub fn filter_by_suffix(&self, suffix: &str) -> Vec<&ZipFileEntry> {
504
13
        self.entries.iter()
505
77
            .filter(|e| !e.is_directory && e.path.ends_with(suffix))
506
13
            .collect()
507
13
    }
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.
519
90
pub fn zip_create(entries: Vec<ZipFileEntry>, config: &ZipWriteConfig) -> Result<Vec<u8>, ZipWriteError> {
520
90
    let zip = ZipFile { entries };
521
90
    zip.to_bytes(config)
522
90
}
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.
529
4
pub fn zip_create_from_files(
530
4
    files: Vec<(String, Vec<u8>)>, 
531
4
    config: &ZipWriteConfig,
532
4
) -> Result<Vec<u8>, ZipWriteError> {
533
4
    let entries: Vec<ZipFileEntry> = files
534
4
        .into_iter()
535
6
        .map(|(path, data)| ZipFileEntry::file(path, data))
536
4
        .collect();
537
4
    zip_create(entries, config)
538
4
}
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.
545
20
pub fn zip_extract_all(data: &[u8], config: &ZipReadConfig) -> Result<Vec<ZipFileEntry>, ZipReadError> {
546
20
    let zip = ZipFile::from_bytes(data, config)?;
547
5
    Ok(zip.entries)
548
20
}
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.
555
19
pub fn zip_list_contents(data: &[u8], config: &ZipReadConfig) -> Result<Vec<ZipPathEntry>, ZipReadError> {
556
19
    ZipFile::list(data, config)
557
19
}
558

            
559
// ============================================================================
560
// Tests
561
// ============================================================================
562

            
563
#[cfg(test)]
564
mod tests {
565
    use super::*;
566
    
567
    #[test]
568
1
    fn test_zip_config_defaults() {
569
1
        let read_config = ZipReadConfig::default();
570
1
        assert_eq!(read_config.max_file_size, 0);
571
1
        assert!(!read_config.allow_path_traversal);
572
        
573
1
        let write_config = ZipWriteConfig::default();
574
1
        assert_eq!(write_config.compression_method, 1);
575
1
        assert_eq!(write_config.compression_level, 6);
576
1
    }
577
    
578
    #[test]
579
1
    fn test_zip_file_entry_creation() {
580
1
        let file = ZipFileEntry::file("test.txt", b"Hello".to_vec());
581
1
        assert_eq!(file.path, "test.txt");
582
1
        assert!(!file.is_directory);
583
1
        assert_eq!(file.data, b"Hello");
584
        
585
1
        let dir = ZipFileEntry::directory("subdir/");
586
1
        assert!(dir.is_directory);
587
1
        assert!(dir.data.is_empty());
588
1
    }
589
    
590
    #[cfg(feature = "zip")]
591
    #[test]
592
1
    fn test_zip_roundtrip() {
593
1
        let files = vec![
594
1
            ("hello.txt".to_string(), b"Hello, World!".to_vec()),
595
1
            ("sub/nested.txt".to_string(), b"Nested file".to_vec()),
596
        ];
597
        
598
1
        let write_config = ZipWriteConfig::default();
599
1
        let zip_data = zip_create_from_files(files, &write_config).expect("Failed to create ZIP");
600
        
601
1
        let read_config = ZipReadConfig::default();
602
1
        let entries = zip_extract_all(&zip_data, &read_config).expect("Failed to extract");
603
        
604
1
        assert_eq!(entries.len(), 2);
605
1
        assert!(entries.iter().any(|e| e.path == "hello.txt"));
606
2
        assert!(entries.iter().any(|e| e.path == "sub/nested.txt"));
607
1
    }
608
    
609
    #[cfg(feature = "zip")]
610
    #[test]
611
1
    fn test_zip_file_manipulation() {
612
1
        let mut zip = ZipFile::new();
613
        
614
1
        zip.add_file("a.txt", b"AAA".to_vec());
615
1
        zip.add_file("b.txt", b"BBB".to_vec());
616
        
617
1
        assert_eq!(zip.entries.len(), 2);
618
1
        assert!(zip.contains("a.txt"));
619
1
        assert!(zip.contains("b.txt"));
620
        
621
1
        zip.remove("a.txt");
622
1
        assert_eq!(zip.entries.len(), 1);
623
1
        assert!(!zip.contains("a.txt"));
624
        
625
        // Overwrite existing
626
1
        zip.add_file("b.txt", b"NEW".to_vec());
627
1
        assert_eq!(zip.entries.len(), 1);
628
1
        assert_eq!(zip.get("b.txt").unwrap().data, b"NEW");
629
1
    }
630
}
631

            
632
// ============================================================================
633
// Autotest: adversarial tests
634
// ============================================================================
635

            
636
#[cfg(test)]
637
mod 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

            
        // every adversarial lookup on an empty archive is None/false, never a panic
        for p in nasty_paths() {
            assert!(a.get(&p).is_none());
            assert!(!a.contains(&p));
        }
        // remove on an empty archive is a no-op
        let mut c = ZipFile::new();
        c.remove("nope");
        c.remove("");
        assert!(c.entries.is_empty());
    }
    #[test]
    fn autotest_add_file_dedup_keeps_last_write() {
        let mut zip = ZipFile::new();
        zip.add_file("a", b"1".to_vec());
        zip.add_file("b", b"2".to_vec());
        zip.add_file("a", b"3".to_vec());
        assert_eq!(zip.entries.len(), 2);
        assert_eq!(zip.get("a").unwrap().data, b"3");
        // the replaced entry is re-appended at the end, so order changes
        assert_eq!(zip.paths(), vec!["b", "a"]);
        // repeated writes to the same path never grow the archive
        for i in 0..100u32 {
            zip.add_file("a", format!("{i}").into_bytes());
        }
        assert_eq!(zip.entries.len(), 2);
        assert_eq!(zip.get("a").unwrap().data, b"99");
    }
    #[test]
    fn autotest_add_directory_and_add_file_share_the_path_namespace() {
        let mut zip = ZipFile::new();
        zip.add_file("x", b"data".to_vec());
        assert!(!zip.get("x").unwrap().is_directory);
        // add_directory replaces a file at the same path
        zip.add_directory("x");
        assert_eq!(zip.entries.len(), 1);
        assert!(zip.get("x").unwrap().is_directory);
        assert!(zip.get("x").unwrap().data.is_empty());
        // ...and vice versa
        zip.add_file("x", b"back".to_vec());
        assert_eq!(zip.entries.len(), 1);
        assert!(!zip.get("x").unwrap().is_directory);
        assert_eq!(zip.get("x").unwrap().data, b"back");
        // "x" and "x/" are *different* paths at this layer
        zip.add_directory("x/");
        assert_eq!(zip.entries.len(), 2);
        assert!(zip.contains("x"));
        assert!(zip.contains("x/"));
    }
    #[test]
    fn autotest_add_and_remove_adversarial_paths_no_panic() {
        let mut zip = ZipFile::new();
        let paths = nasty_paths();
        for (i, p) in paths.iter().enumerate() {
            zip.add_file(p.clone(), vec![u8::try_from(i % 256).unwrap()]);
        }
        // nasty_paths() has no duplicates, so every path survived
        assert_eq!(zip.entries.len(), paths.len());
        for p in &paths {
            assert!(zip.contains(p), "lost path {p:?}");
            assert!(zip.get(p).is_some());
        }
        for p in &paths {
            zip.remove(p);
            assert!(!zip.contains(p));
        }
        assert!(zip.entries.is_empty());
        // removing a path that is a *prefix*/*suffix* of a stored path must not match
        let mut zip = ZipFile::new();
        zip.add_file("dir/file.txt", b"d".to_vec());
        zip.remove("dir/");
        zip.remove("file.txt");
        zip.remove("dir/file.tx");
        zip.remove("dir/file.txt ");
        assert_eq!(zip.entries.len(), 1, "remove() must match the whole path only");
        zip.remove("dir/file.txt");
        assert!(zip.entries.is_empty());
    }
    #[test]
    fn autotest_get_and_contains_agree_and_reject_junk() {
        let mut zip = ZipFile::new();
        zip.add_file("a.txt", b"A".to_vec());
        zip.add_file("\u{1F600}.txt", b"E".to_vec());
        zip.add_directory("sub/");
        // exact matches only
        assert!(zip.contains("a.txt"));
        assert!(zip.contains("\u{1F600}.txt"));
        assert!(zip.contains("sub/"));
        // leading/trailing junk, case changes and near-misses are all rejected
        for p in [
            " a.txt", "a.txt ", "A.TXT", "a.txt\0", "./a.txt", "/a.txt", "a.txt;x", "sub", "sub//",
            "\u{1F600}", "\u{1F600}.TXT",
        ] {
            assert!(!zip.contains(p), "unexpected match for {p:?}");
            assert!(zip.get(p).is_none());
        }
        // get()/contains() must never disagree, for any input
        for p in nasty_paths() {
            assert_eq!(zip.get(&p).is_some(), zip.contains(&p), "disagree on {p:?}");
        }
        // a 1M-char probe neither panics nor hangs
        let huge = "y".repeat(1_000_000);
        assert!(zip.get(&huge).is_none());
        assert!(!zip.contains(&huge));
    }
    #[test]
    fn autotest_paths_mirrors_entries_in_order() {
        let mut zip = ZipFile::new();
        assert!(zip.paths().is_empty());
        for i in 0..50u32 {
            zip.add_file(format!("f{i}"), vec![u8::try_from(i).unwrap()]);
        }
        zip.add_directory("d/");
        let paths = zip.paths();
        assert_eq!(paths.len(), zip.entries.len());
        for (p, e) in paths.iter().zip(zip.entries.iter()) {
            assert_eq!(*p, e.path.as_str());
        }
        // directories are included in paths()
        assert!(paths.contains(&"d/"));
        // duplicates constructed directly are all reported
        let dup = ZipFile {
            entries: vec![
                ZipFileEntry::file("same", b"1".to_vec()),
                ZipFileEntry::file("same", b"2".to_vec()),
            ],
        };
        assert_eq!(dup.paths(), vec!["same", "same"]);
        // get() returns the *first* match
        assert_eq!(dup.get("same").unwrap().data, b"1");
        assert!(dup.contains("same"));
        // ...and remove() drops every duplicate
        let mut dup = dup;
        dup.remove("same");
        assert!(dup.entries.is_empty());
    }
    #[test]
    fn autotest_filter_by_suffix_edge_cases() {
        let zip = ZipFile {
            entries: vec![
                ZipFileEntry::file("a.txt", b"1".to_vec()),
                ZipFileEntry::file("b.TXT", b"2".to_vec()),
                ZipFileEntry::file("README", b"3".to_vec()),
                ZipFileEntry::file("", b"4".to_vec()),
                ZipFileEntry::file("\u{1F600}.json", b"5".to_vec()),
                ZipFileEntry::directory("dir.txt"),
                ZipFileEntry::directory("sub/"),
            ],
        };
        // empty suffix matches every *non-directory* entry
        assert_eq!(zip.filter_by_suffix("").len(), 5);
        assert!(zip.filter_by_suffix("").iter().all(|e| !e.is_directory));
        // directories are excluded even when their path ends with the suffix
        let txt = zip.filter_by_suffix(".txt");
        assert_eq!(txt.len(), 1);
        assert_eq!(txt[0].path, "a.txt");
        // matching is case-sensitive
        assert_eq!(zip.filter_by_suffix(".TXT").len(), 1);
        assert_eq!(zip.filter_by_suffix(".Txt").len(), 0);
        // whole-path suffix matches
        assert_eq!(zip.filter_by_suffix("README").len(), 1);
        // multibyte suffix must not split a char boundary or panic
        assert_eq!(zip.filter_by_suffix("\u{1F600}.json").len(), 1);
        assert_eq!(zip.filter_by_suffix("json").len(), 1);
        // suffix longer than any path -> empty, no panic
        assert!(zip.filter_by_suffix(&"n".repeat(100_000)).is_empty());
        // junk suffixes
        assert!(zip.filter_by_suffix("\0").is_empty());
        assert!(zip.filter_by_suffix("  ").is_empty());
    }
    // ==================================================================
    // parsers: malformed / hostile input
    // ==================================================================
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_readers_reject_empty_and_garbage_without_panicking() {
        let cfg = ZipReadConfig::default();
        let inputs: Vec<Vec<u8>> = vec![
            Vec::new(),
            b"   ".to_vec(),
            b"\t\n\r ".to_vec(),
            b"not a zip file at all".to_vec(),
            vec![0u8; 22],
            vec![0xFF, 0xFE, 0x00],
            vec![0xC3, 0x28, 0xA0, 0xA1],           // invalid UTF-8
            b"PK".to_vec(),                          // truncated signature
            b"PK\x03\x04".to_vec(),                  // local header signature only
            b"PK\x05\x06".to_vec(),                  // truncated EOCD
            b"0 -0 NaN inf 9223372036854775807".to_vec(),
            "\u{1F600}\u{0301}".as_bytes().to_vec(), // multibyte unicode
            b"[".repeat(10_000),                     // "deeply nested" junk
            b"PK\x05\x06".repeat(5_000),             // many EOCD-ish signatures
        ];
        for data in inputs {
            let listed = ZipFile::list(&data, &cfg);
            let loaded = ZipFile::from_bytes(&data, &cfg);
            let extracted = zip_extract_all(&data, &cfg);
            let contents = zip_list_contents(&data, &cfg);
            // the free functions must agree with the inherent methods
            assert_eq!(loaded.is_err(), extracted.is_err());
            assert_eq!(listed.is_err(), contents.is_err());
            match loaded {
                Err(e) => {
                    // garbage must surface as a *parse* failure, never as a
                    // security/limit verdict (UnsafePath / FileTooLarge / ...)
                    assert!(
                        matches!(
                            e,
                            ZipReadError::InvalidFormat(_) | ZipReadError::IoError(_)
                        ),
                        "unexpected error kind for {:?}: {e:?}",
                        &data[..data.len().min(8)]
                    );
                    assert!(!e.to_string().is_empty());
                }
                // if it *did* parse, it must be a degenerate empty archive
                Ok(z) => assert!(z.entries.is_empty()),
            }
        }
        // empty input specifically is a format error, not an I/O error
        assert!(matches!(
            ZipFile::from_bytes(b"", &cfg),
            Err(ZipReadError::InvalidFormat(_))
        ));
        assert!(matches!(
            ZipFile::list(b"", &cfg),
            Err(ZipReadError::InvalidFormat(_))
        ));
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_readers_handle_one_megabyte_of_junk() {
        let cfg = ZipReadConfig::default();
        // 1 MiB with no valid central directory: must fail fast, not hang or OOM
        let junk = vec![b'A'; 1_000_000];
        assert!(ZipFile::from_bytes(&junk, &cfg).is_err());
        assert!(ZipFile::list(&junk, &cfg).is_err());
        // 1 MiB of zeros (a plausible sparse/zeroed file)
        let zeros = vec![0u8; 1_000_000];
        assert!(ZipFile::from_bytes(&zeros, &cfg).is_err());
        // 1 MiB ending in something that looks like an EOCD but isn't consistent
        let mut fake = vec![b'B'; 1_000_000];
        fake.extend_from_slice(&[0x50, 0x4B, 0x05, 0x06]);
        fake.extend_from_slice(&[0xFFu8; 18]);
        let res = ZipFile::from_bytes(&fake, &cfg);
        assert!(
            res.map_or(true, |z| z.entries.is_empty()),
            "a bogus EOCD must not yield phantom entries"
        );
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_minimal_valid_archives_parse_as_empty() {
        let cfg = ZipReadConfig::default();
        // positive control #1: what this module itself writes for an empty archive
        let own = ZipFile::new()
            .to_bytes(&ZipWriteConfig::default())
            .expect("empty archive must be writable");
        let round = ZipFile::from_bytes(&own, &cfg).expect("own empty archive must re-read");
        assert!(round.entries.is_empty());
        assert!(ZipFile::list(&own, &cfg).unwrap().is_empty());
        // an empty archive is also writable with the store() config (no file entries)
        assert!(ZipFile::new().to_bytes(&ZipWriteConfig::store()).is_ok());
        // positive control #2: the canonical 22-byte EOCD-only archive
        let eocd = eocd_only();
        assert_eq!(eocd.len(), 22);
        if let Ok(z) = ZipFile::from_bytes(&eocd, &cfg) {
            assert!(z.entries.is_empty());
        }
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_truncated_and_bitflipped_archives_never_panic() {
        let cfg = ZipReadConfig::default();
        let good = build(vec![
            ZipFileEntry::file("a.txt", b"hello hello hello hello".to_vec()),
            ZipFileEntry::file("b.bin", vec![7u8; 512]),
        ]);
        assert!(ZipFile::from_bytes(&good, &cfg).is_ok());
        // every truncation prefix must be handled (Err or degenerate Ok), never a panic
        for cut in [0, 1, 3, 4, 10, good.len() / 4, good.len() / 2, good.len() - 1] {
            let _ = ZipFile::from_bytes(&good[..cut], &cfg);
            let _ = ZipFile::list(&good[..cut], &cfg);
        }
        // single-byte corruption anywhere in the stream
        for i in (0..good.len()).step_by(7) {
            let mut bad = good.clone();
            bad[i] ^= 0xFF;
            let _ = ZipFile::from_bytes(&bad, &cfg);
            let _ = ZipFile::list(&bad, &cfg);
        }
        // trailing junk appended after the EOCD
        let mut trailing = good.clone();
        trailing.extend_from_slice(b"garbage;garbage");
        let _ = ZipFile::from_bytes(&trailing, &cfg);
        // leading junk prepended before the local headers
        let mut leading = b"JUNK".to_vec();
        leading.extend_from_slice(&good);
        let _ = ZipFile::from_bytes(&leading, &cfg);
    }
    // ==================================================================
    // round-trip: encode == decode
    // ==================================================================
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_roundtrip_all_byte_values_and_empty_files() {
        let all_bytes: Vec<u8> = (0..=255u8).collect();
        let entries = vec![
            ZipFileEntry::file("bytes.bin", all_bytes.clone()),
            ZipFileEntry::file("empty.bin", Vec::new()),
            ZipFileEntry::file("one.bin", vec![0u8]),
        ];
        let bytes = build(entries);
        let cfg = ZipReadConfig::default();
        let round = ZipFile::from_bytes(&bytes, &cfg).unwrap();
        assert_eq!(round.entries.len(), 3);
        assert_eq!(round.paths(), vec!["bytes.bin", "empty.bin", "one.bin"]);
        assert_eq!(round.get("bytes.bin").unwrap().data, all_bytes);
        assert!(round.get("empty.bin").unwrap().data.is_empty());
        assert_eq!(round.get("one.bin").unwrap().data, vec![0u8]);
        assert!(round.entries.iter().all(|e| !e.is_directory));
        // re-encoding the decoded archive yields the same decoded content
        let again = round.to_bytes(&ZipWriteConfig::default()).unwrap();
        let round2 = ZipFile::from_bytes(&again, &cfg).unwrap();
        assert_eq!(round2.paths(), round.paths());
        for e in &round.entries {
            assert_eq!(round2.get(&e.path).unwrap().data, e.data);
        }
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_roundtrip_unicode_paths_and_content() {
        let paths = [
            "\u{1F600}.txt",
            "e\u{0301}\u{0301}combining.txt",
            "\u{4F60}\u{597D}/\u{4E16}\u{754C}.txt",
            "\u{FEFF}bom.txt",
            "spaces   and\ttabs.txt",
        ];
        let entries: Vec<ZipFileEntry> = paths
            .iter()
            .enumerate()
            .map(|(i, p)| ZipFileEntry::file(*p, format!("payload \u{1F9F0} {i}").into_bytes()))
            .collect();
        let bytes = build(entries);
        let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
        assert_eq!(round.entries.len(), paths.len());
        for (i, p) in paths.iter().enumerate() {
            let e = round
                .get(p)
                .unwrap_or_else(|| panic!("unicode path {p:?} was not preserved"));
            assert_eq!(e.data, format!("payload \u{1F9F0} {i}").into_bytes());
        }
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_roundtrip_deep_paths_and_large_payload() {
        // ~4 KiB deeply nested path (2000 components) - must not stack-overflow
        let deep = "a/".repeat(2000) + "leaf.txt";
        assert!(!deep.contains(".."));
        // 100 KiB payload with a non-degenerate byte distribution
        let big: Vec<u8> = (0..100_000u32).map(|i| u8::try_from(i % 251).unwrap()).collect();
        let bytes = build(vec![
            ZipFileEntry::file(deep.clone(), b"leaf".to_vec()),
            ZipFileEntry::file("big.bin", big.clone()),
        ]);
        let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
        assert_eq!(round.get(&deep).unwrap().data, b"leaf");
        assert_eq!(round.get("big.bin").unwrap().data, big);
        // list() reports the true uncompressed size for the large entry
        let listed = ZipFile::list(&bytes, &ZipReadConfig::default()).unwrap();
        let big_meta = listed.iter().find(|e| e.path == "big.bin").unwrap();
        assert_eq!(big_meta.size, 100_000);
        assert!(!big_meta.is_directory);
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_roundtrip_directory_entries_get_a_trailing_slash() {
        let bytes = build(vec![
            ZipFileEntry::directory("with_slash/"),
            ZipFileEntry::directory("no_slash"),
            ZipFileEntry::file("f.txt", b"x".to_vec()),
        ]);
        let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
        assert_eq!(round.entries.len(), 3);
        let with = round.get("with_slash/").expect("dir with slash preserved");
        assert!(with.is_directory);
        assert!(with.data.is_empty());
        // NOTE: the underlying writer rewrites "no_slash" -> "no_slash/", so the
        // path that comes back is NOT the path that went in. Asserted, not fixed.
        assert!(round.get("no_slash").is_none());
        let without = round.get("no_slash/").expect("dir without slash was rewritten");
        assert!(without.is_directory);
        assert!(!round.get("f.txt").unwrap().is_directory);
        // list() agrees about directory-ness
        let listed = ZipFile::list(&bytes, &ZipReadConfig::default()).unwrap();
        assert_eq!(listed.len(), 3);
        assert_eq!(listed.iter().filter(|e| e.is_directory).count(), 2);
        for d in listed.iter().filter(|e| e.is_directory) {
            assert_eq!(d.size, 0);
            assert!(d.path.ends_with('/'));
        }
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_roundtrip_survives_config_extremes() {
        let entries = || vec![ZipFileEntry::file("f.bin", vec![9u8; 3000])];
        // every deflate level that the writer accepts
        for level in 1..=9u8 {
            let cfg = ZipWriteConfig::deflate(level);
            let bytes = zip_create(entries(), &cfg)
                .unwrap_or_else(|e| panic!("deflate({level}) failed: {e}"));
            let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
            assert_eq!(round.get("f.bin").unwrap().data, vec![9u8; 3000]);
        }
        // deflate() saturates, so 10..=255 all behave like 9
        for level in [10u8, 100, u8::MAX] {
            let bytes = zip_create(entries(), &ZipWriteConfig::deflate(level)).unwrap();
            let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
            assert_eq!(round.get("f.bin").unwrap().data.len(), 3000);
        }
        // u32::MAX permissions must not overflow or corrupt the archive
        let mut cfg = ZipWriteConfig {
            unix_permissions: u32::MAX,
            ..Default::default()
        };
        let bytes = zip_create(entries(), &cfg).unwrap();
        assert_eq!(
            ZipFile::from_bytes(&bytes, &ZipReadConfig::default())
                .unwrap()
                .get("f.bin")
                .unwrap()
                .data
                .len(),
            3000
        );
        cfg.unix_permissions = 0;
        assert!(zip_create(entries(), &cfg).is_ok());
        // a unicode archive comment keeps the EOCD findable
        let cfg = ZipWriteConfig::default().with_comment("\u{1F5DC}\u{FE0F} t\u{E9}st comment");
        let bytes = zip_create(entries(), &cfg).unwrap();
        let round = ZipFile::from_bytes(&bytes, &ZipReadConfig::default()).unwrap();
        assert_eq!(round.entries.len(), 1);
        // an over-long comment (past the u16 EOCD length field) must not panic
        let cfg = ZipWriteConfig::default().with_comment("c".repeat(70_000));
        let _ = zip_create(entries(), &cfg);
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_convenience_functions_agree_with_methods() {
        let files = vec![
            ("a.txt".to_string(), b"AAA".to_vec()),
            ("dir/b.bin".to_string(), vec![0u8, 255, 128]),
        ];
        let cfg = ZipWriteConfig::default();
        let via_files = zip_create_from_files(files.clone(), &cfg).unwrap();
        let via_entries = zip_create(
            files
                .iter()
                .map(|(p, d)| ZipFileEntry::file(p.clone(), d.clone()))
                .collect(),
            &cfg,
        )
        .unwrap();
        let via_method = ZipFile {
            entries: files
                .iter()
                .map(|(p, d)| ZipFileEntry::file(p.clone(), d.clone()))
                .collect(),
        }
        .to_bytes(&cfg)
        .unwrap();
        let rcfg = ZipReadConfig::default();
        for bytes in [&via_files, &via_entries, &via_method] {
            let extracted = zip_extract_all(bytes, &rcfg).unwrap();
            let loaded = ZipFile::from_bytes(bytes, &rcfg).unwrap();
            assert_eq!(extracted.len(), 2);
            assert_eq!(loaded.entries.len(), 2);
            for (i, (p, d)) in files.iter().enumerate() {
                assert_eq!(&extracted[i].path, p);
                assert_eq!(&extracted[i].data, d);
                assert_eq!(&loaded.entries[i].path, p);
            }
            // zip_list_contents == ZipFile::list, and paths/sizes match the data
            let listed = zip_list_contents(bytes, &rcfg).unwrap();
            let listed2 = ZipFile::list(bytes, &rcfg).unwrap();
            assert_eq!(listed.len(), listed2.len());
            for (a, b) in listed.iter().zip(listed2.iter()) {
                assert_eq!(a.path, b.path);
                assert_eq!(a.size, b.size);
                assert_eq!(a.compressed_size, b.compressed_size);
                assert_eq!(a.crc32, b.crc32);
                assert_eq!(a.is_directory, b.is_directory);
            }
            for (meta, (p, d)) in listed.iter().zip(files.iter()) {
                assert_eq!(&meta.path, p);
                assert_eq!(meta.size, d.len() as u64);
                assert!(!meta.is_directory);
            }
        }
        // empty input list -> valid empty archive
        let empty = zip_create_from_files(Vec::new(), &cfg).unwrap();
        assert!(zip_extract_all(&empty, &rcfg).unwrap().is_empty());
        assert!(zip_list_contents(&empty, &rcfg).unwrap().is_empty());
    }
    // ==================================================================
    // security checks: path traversal + size limits
    // ==================================================================
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_path_traversal_check_is_a_plain_substring_test() {
        // "a..b.txt" is NOT a traversal, but the check is `path.contains("..")`,
        // so it is rejected anyway. Asserting the real (over-strict) behaviour.
        let bytes = build(vec![
            ZipFileEntry::file("a..b.txt", b"harmless".to_vec()),
            ZipFileEntry::file("ok.txt", b"ok".to_vec()),
        ]);
        let strict = ZipReadConfig::default();
        match ZipFile::from_bytes(&bytes, &strict) {
            Err(ZipReadError::UnsafePath(p)) => assert_eq!(p, "a..b.txt"),
            other => panic!("expected UnsafePath, got {other:?}"),
        }
        match ZipFile::list(&bytes, &strict) {
            Err(ZipReadError::UnsafePath(p)) => assert_eq!(p, "a..b.txt"),
            other => panic!("expected UnsafePath from list(), got {other:?}"),
        }
        // ...and the whole archive is rejected, not just the offending entry
        let loose = ZipReadConfig::new().with_allow_path_traversal(true);
        let round = ZipFile::from_bytes(&bytes, &loose).unwrap();
        assert_eq!(round.entries.len(), 2);
        assert_eq!(round.get("a..b.txt").unwrap().data, b"harmless");
        assert_eq!(ZipFile::list(&bytes, &loose).unwrap().len(), 2);
        // a real traversal path is rejected under the strict config too
        let evil = build(vec![ZipFileEntry::file("../../etc/passwd", b"x".to_vec())]);
        assert!(matches!(
            ZipFile::from_bytes(&evil, &strict),
            Err(ZipReadError::UnsafePath(_))
        ));
        assert!(ZipFile::from_bytes(&evil, &loose).is_ok());
        // a path with a single dot is fine
        let dotted = build(vec![ZipFileEntry::file("./a.txt", b"x".to_vec())]);
        assert!(ZipFile::from_bytes(&dotted, &strict).is_ok());
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_max_file_size_is_enforced_by_from_bytes_only() {
        let payload = vec![b'q'; 1000];
        let bytes = build(vec![ZipFileEntry::file("big.bin", payload.clone())]);
        // 0 means unlimited
        let unlimited = ZipReadConfig::new().with_max_file_size(0);
        assert_eq!(
            ZipFile::from_bytes(&bytes, &unlimited).unwrap().entries[0].data,
            payload
        );
        // exactly at the limit is allowed; one below is not
        let at = ZipReadConfig::new().with_max_file_size(1000);
        assert!(ZipFile::from_bytes(&bytes, &at).is_ok());
        let under = ZipReadConfig::new().with_max_file_size(999);
        match ZipFile::from_bytes(&bytes, &under) {
            Err(ZipReadError::FileTooLarge { path, size, max_size }) => {
                assert_eq!(path, "big.bin");
                assert_eq!(size, 1000);
                assert_eq!(max_size, 999);
            }
            other => panic!("expected FileTooLarge, got {other:?}"),
        }
        assert!(ZipFile::from_bytes(&bytes, &ZipReadConfig::new().with_max_file_size(1)).is_err());
        assert!(zip_extract_all(&bytes, &under).is_err());
        // NOTE: list() deliberately ignores max_file_size (metadata only), so a
        // 1-byte limit still lists a 1000-byte entry. Documented, not enforced.
        let listed = ZipFile::list(&bytes, &under).unwrap();
        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].size, 1000);
        assert!(listed[0].compressed_size > 0);
        assert_eq!(zip_list_contents(&bytes, &under).unwrap().len(), 1);
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_get_single_file_lookup_semantics() {
        let bytes = build(vec![
            ZipFileEntry::file("a.txt", b"AAA".to_vec()),
            ZipFileEntry::directory("sub/"),
        ]);
        let cfg = ZipReadConfig::default();
        let meta = ZipFile::list(&bytes, &cfg).unwrap();
        // positive control: every listed entry is retrievable and matches from_bytes
        let loaded = ZipFile::from_bytes(&bytes, &cfg).unwrap();
        for m in &meta {
            let got = ZipFile::get_single_file(&bytes, m, &cfg).unwrap();
            assert_eq!(got.as_deref(), Some(loaded.get(&m.path).unwrap().data.as_slice()));
        }
        // a directory yields an empty payload, not an error
        let dir = meta.iter().find(|m| m.is_directory).unwrap();
        assert_eq!(ZipFile::get_single_file(&bytes, dir, &cfg).unwrap(), Some(Vec::new()));
        // missing / junk paths return Ok(None), never Err and never a panic
        for p in nasty_paths() {
            let entry = ZipPathEntry {
                path: p.clone(),
                is_directory: false,
                size: 0,
                compressed_size: 0,
                crc32: 0,
            };
            assert_eq!(
                ZipFile::get_single_file(&bytes, &entry, &cfg).unwrap(),
                None,
                "expected None for {p:?}"
            );
        }
        // malformed archive data surfaces as InvalidFormat
        let entry = ZipPathEntry {
            path: "a.txt".into(),
            is_directory: false,
            size: 3,
            compressed_size: 3,
            crc32: 0,
        };
        for junk in [b"".as_slice(), b"   ", b"nope", &[0xFF, 0xFE, 0x00]] {
            assert!(matches!(
                ZipFile::get_single_file(junk, &entry, &cfg),
                Err(ZipReadError::InvalidFormat(_))
            ));
        }
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_get_single_file_size_check_runs_before_parsing() {
        // The limit check is done on the caller-supplied entry, before the archive
        // is even opened - so garbage bytes still yield FileTooLarge.
        let cfg = ZipReadConfig::new().with_max_file_size(10);
        let entry = ZipPathEntry {
            path: "x".into(),
            is_directory: false,
            size: 11,
            compressed_size: 0,
            crc32: 0,
        };
        match ZipFile::get_single_file(b"total garbage", &entry, &cfg) {
            Err(ZipReadError::FileTooLarge { path, size, max_size }) => {
                assert_eq!(path, "x");
                assert_eq!(size, 11);
                assert_eq!(max_size, 10);
            }
            other => panic!("expected FileTooLarge before parsing, got {other:?}"),
        }
        // boundary: size == max is allowed through to the parser
        let at_limit = ZipPathEntry { size: 10, ..entry.clone() };
        assert!(matches!(
            ZipFile::get_single_file(b"total garbage", &at_limit, &cfg),
            Err(ZipReadError::InvalidFormat(_))
        ));
        // max_file_size == 0 disables the check entirely, even for u64::MAX sizes
        let unlimited = ZipReadConfig::default();
        let huge = ZipPathEntry { size: u64::MAX, ..entry };
        assert!(matches!(
            ZipFile::get_single_file(b"total garbage", &huge, &unlimited),
            Err(ZipReadError::InvalidFormat(_))
        ));
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_get_single_file_trusts_the_callers_metadata() {
        // BUG (documented, not fixed): get_single_file checks `entry.size` -- which
        // the caller (or a hostile archive header) supplies -- instead of the real
        // entry size, so a lying entry walks straight past max_file_size.
        let payload = vec![b'z'; 5000];
        let bytes = build(vec![ZipFileEntry::file("big.bin", payload.clone())]);
        let capped = ZipReadConfig::new().with_max_file_size(10);
        let liar = ZipPathEntry {
            path: "big.bin".into(),
            is_directory: false,
            size: 0, // lie: real size is 5000
            compressed_size: 0,
            crc32: 0,
        };
        let got = ZipFile::get_single_file(&bytes, &liar, &capped).unwrap();
        assert_eq!(
            got,
            Some(payload),
            "the 10-byte cap was bypassed by a lying entry.size"
        );
        // ...while from_bytes with the same config correctly refuses:
        assert!(matches!(
            ZipFile::from_bytes(&bytes, &capped),
            Err(ZipReadError::FileTooLarge { .. })
        ));
        // BUG (documented, not fixed): get_single_file also performs no path
        // traversal check at all, unlike list()/from_bytes().
        let bytes = build(vec![ZipFileEntry::file("../evil.txt", b"pwned".to_vec())]);
        let strict = ZipReadConfig::default();
        assert!(matches!(
            ZipFile::from_bytes(&bytes, &strict),
            Err(ZipReadError::UnsafePath(_))
        ));
        let entry = ZipPathEntry {
            path: "../evil.txt".into(),
            is_directory: false,
            size: 5,
            compressed_size: 5,
            crc32: 0,
        };
        assert_eq!(
            ZipFile::get_single_file(&bytes, &entry, &strict).unwrap(),
            Some(b"pwned".to_vec()),
            "get_single_file has no UnsafePath guard"
        );
    }
    /// BUG (documented, not fixed): `get_single_file` does
    /// `Vec::with_capacity(usize::try_from(entry.size).unwrap_or(0))` on the
    /// *declared* size. A hostile archive header (surfaced verbatim by `list()`)
    /// declaring `u64::MAX` therefore aborts the process with "capacity overflow"
    /// before a single byte is read. Should be a bounded/incremental read.
    #[cfg(all(feature = "zip", target_pointer_width = "64"))]
    #[test]
    #[should_panic]
    fn autotest_bug_get_single_file_capacity_overflow_on_declared_size() {
        let bytes = build(vec![ZipFileEntry::file("a.txt", b"AAA".to_vec())]);
        let entry = ZipPathEntry {
            path: "a.txt".into(),
            is_directory: false,
            size: u64::MAX, // max_file_size == 0 means "unlimited", so this passes the check
            compressed_size: 3,
            crc32: 0,
        };
        let _ = ZipFile::get_single_file(&bytes, &entry, &ZipReadConfig::default());
    }
    // ==================================================================
    // writer: configurations that cannot produce an archive
    // ==================================================================
    /// BUG (documented, not fixed): `to_bytes` always passes
    /// `compression_level(Some(..))`, but the backend rejects *any* explicit level
    /// for `Stored`. `ZipWriteConfig::store()` therefore cannot write a single
    /// file entry -- uncompressed archives are unreachable through this API.
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_bug_store_config_cannot_write_file_entries() {
        let cfg = ZipWriteConfig::store();
        let err = zip_create(vec![ZipFileEntry::file("a.txt", b"A".to_vec())], &cfg)
            .expect_err("store() unexpectedly produced an archive");
        assert!(
            err.to_string().contains("compression level"),
            "unexpected error for store(): {err}"
        );
        assert!(matches!(err, ZipWriteError::IoError(_)));
        // ...but an archive with no file entries still succeeds, which makes the
        // failure look intermittent to callers.
        assert!(ZipFile::new().to_bytes(&cfg).is_ok());
        // any compression_method != 0 maps to Deflate and works
        let mut deflate_ish = ZipWriteConfig::store();
        deflate_ish.compression_method = 2;
        deflate_ish.compression_level = 6;
        assert!(zip_create(vec![ZipFileEntry::file("a.txt", b"A".to_vec())], &deflate_ish).is_ok());
    }
    /// BUG (documented, not fixed): `ZipWriteConfig::deflate(0)` is accepted by the
    /// builder (`0.min(9) == 0`) but the deflate backend's valid level range starts
    /// at 1, so the resulting config can never write a file.
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_bug_deflate_level_zero_is_unwritable() {
        let cfg = ZipWriteConfig::deflate(0);
        assert_eq!(cfg.compression_level, 0, "builder accepted level 0");
        let err = zip_create(vec![ZipFileEntry::file("a.txt", b"A".to_vec())], &cfg)
            .expect_err("deflate(0) unexpectedly produced an archive");
        assert!(
            err.to_string().contains("compression level"),
            "unexpected error for deflate(0): {err}"
        );
        // level 1 is the first level that actually works
        assert!(zip_create(vec![ZipFileEntry::file("a.txt", b"A".to_vec())], &ZipWriteConfig::deflate(1)).is_ok());
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_duplicate_paths_make_the_archive_unwritable() {
        // add_file() de-duplicates, but ZipFile.entries is a public field and
        // zip_create() takes an arbitrary Vec, so duplicates reach the writer.
        let cfg = ZipWriteConfig::default();
        let err = zip_create(
            vec![
                ZipFileEntry::file("dup.txt", b"1".to_vec()),
                ZipFileEntry::file("dup.txt", b"2".to_vec()),
            ],
            &cfg,
        )
        .expect_err("duplicate paths unexpectedly accepted");
        assert!(matches!(err, ZipWriteError::IoError(_)));
        assert!(!err.to_string().is_empty());
        // zip_create_from_files has the same hazard
        assert!(zip_create_from_files(
            vec![
                ("d".to_string(), b"1".to_vec()),
                ("d".to_string(), b"2".to_vec()),
            ],
            &cfg
        )
        .is_err());
        // going through add_file() is safe because it de-duplicates first
        let mut zip = ZipFile::new();
        zip.add_file("dup.txt", b"1".to_vec());
        zip.add_file("dup.txt", b"2".to_vec());
        let bytes = zip.to_bytes(&cfg).unwrap();
        assert_eq!(
            ZipFile::from_bytes(&bytes, &ZipReadConfig::default())
                .unwrap()
                .get("dup.txt")
                .unwrap()
                .data,
            b"2"
        );
    }
    #[cfg(feature = "zip")]
    #[test]
    fn autotest_to_bytes_with_hostile_paths_never_panics() {
        let cfg = ZipWriteConfig::default();
        let loose = ZipReadConfig::new().with_allow_path_traversal(true);
        // BUG (documented, NOT exercised here): nothing validates path length, and
        // the ZIP file-name field is a u16. A path of 65_536+ bytes panics inside
        // the writer (`file_name_raw.len().try_into().unwrap()`) instead of
        // returning `ZipWriteError::InvalidPath`. It cannot be asserted with
        // #[should_panic] because ZipWriter::drop re-panics on the same unwrap
        // while unwinding, which aborts the process. Hence the <60_000 filter.
        // Each path is written into its own archive so one rejection does not mask
        // the others; the contract under test is "Ok or Err, never a panic".
        for p in nasty_paths().into_iter().filter(|p| p.len() < 60_000) {
            if let Ok(bytes) = zip_create(vec![ZipFileEntry::file(p.clone(), b"x".to_vec())], &cfg)
            {
                // if it encoded, it must decode back without panicking
                let _ = ZipFile::from_bytes(&bytes, &loose);
            }
            if let Ok(bytes) = zip_create(vec![ZipFileEntry::directory(p)], &cfg) {
                let _ = ZipFile::from_bytes(&bytes, &loose);
            }
        }
        // a 60_000-byte path is under the u16 field limit and must round-trip
        let long = "L".repeat(60_000);
        let bytes = zip_create(vec![ZipFileEntry::file(long.clone(), b"x".to_vec())], &cfg)
            .expect("60_000-byte path must be writable");
        assert_eq!(
            ZipFile::from_bytes(&bytes, &loose).unwrap().get(&long).unwrap().data,
            b"x"
        );
    }
    // ==================================================================
    // file-system entry points
    // ==================================================================
    #[cfg(all(feature = "zip", feature = "std"))]
    #[test]
    fn autotest_from_file_missing_path_is_io_error() {
        let cfg = ZipReadConfig::default();
        for p in [
            "/nonexistent_dir_azul_autotest_zip/sub/archive.zip",
            "",
            "/nonexistent_dir_azul_autotest_zip/\u{1F600}.zip",
        ] {
            match ZipFile::from_file(Path::new(p), &cfg) {
                Err(ZipReadError::IoError(msg)) => assert!(!msg.is_empty()),
                other => panic!("expected IoError for {p:?}, got {other:?}"),
            }
        }
        // a directory is not a readable archive either
        let tmp = std::env::temp_dir();
        assert!(ZipFile::from_file(&tmp, &cfg).is_err());
    }
    #[cfg(all(feature = "zip", feature = "std"))]
    #[test]
    fn autotest_to_file_unwritable_path_is_io_error() {
        let mut zip = ZipFile::new();
        zip.add_file("a.txt", b"A".to_vec());
        let cfg = ZipWriteConfig::default();
        match zip.to_file(
            Path::new("/nonexistent_dir_azul_autotest_zip/sub/out.zip"),
            &cfg,
        ) {
            Err(ZipWriteError::IoError(msg)) => assert!(!msg.is_empty()),
            other => panic!("expected IoError, got {other:?}"),
        }
        // a write-config failure is reported before the filesystem is touched
        let store = ZipWriteConfig::store();
        assert!(zip.to_file(Path::new("/nonexistent_dir_azul_autotest_zip/x.zip"), &store).is_err());
    }
    #[cfg(all(feature = "zip", feature = "std"))]
    #[test]
    fn autotest_file_roundtrip_via_temp_dir() {
        let mut zip = ZipFile::new();
        zip.add_file("a.txt", b"AAA".to_vec());
        zip.add_file("\u{1F600}/b.bin", vec![0u8, 255, 128]);
        zip.add_directory("d/");
        let path = std::env::temp_dir().join(format!(
            "azul_autotest_zip_roundtrip_{}.zip",
            std::process::id()
        ));
        let _ = std::fs::remove_file(&path);
        match zip.to_file(&path, &ZipWriteConfig::default()) {
            Ok(()) => {
                let round = ZipFile::from_file(&path, &ZipReadConfig::default())
                    .expect("archive written by to_file must be readable");
                assert_eq!(round.entries.len(), 3);
                assert_eq!(round.get("a.txt").unwrap().data, b"AAA");
                assert_eq!(round.get("\u{1F600}/b.bin").unwrap().data, vec![0u8, 255, 128]);
                assert!(round.get("d/").unwrap().is_directory);
                // to_file and to_bytes must produce identical content
                let in_memory = zip.to_bytes(&ZipWriteConfig::default()).unwrap();
                let on_disk = std::fs::read(&path).unwrap();
                assert_eq!(in_memory.len(), on_disk.len());
                let _ = std::fs::remove_file(&path);
            }
            Err(ZipWriteError::IoError(_)) => {
                // temp dir not writable in this environment - nothing to assert
            }
            Err(other) => panic!("unexpected write error: {other:?}"),
        }
    }
}