Skip to main content

s_zip/
lib.rs

1//! # s-zip: High-Performance Streaming ZIP Library
2//!
3//! `s-zip` is a lightweight, high-performance ZIP library focused on streaming operations
4//! with minimal memory footprint. Perfect for working with large ZIP files without loading
5//! everything into memory.
6//!
7//! ## Features
8//!
9//! - **Streaming Read**: Read ZIP entries on-the-fly without loading entire archive
10//! - **Streaming Write**: Write ZIP files with on-the-fly compression, no temp files
11//! - **Low Memory**: Constant memory usage regardless of ZIP file size
12//! - **Fast**: Optimized for performance with minimal allocations
13//! - **Simple API**: Easy to use, intuitive interface
14//!
15//! ## Quick Start
16//!
17//! ### Reading a ZIP file
18//!
19//! ```no_run
20//! use s_zip::StreamingZipReader;
21//!
22//! let mut reader = StreamingZipReader::open("archive.zip")?;
23//!
24//! // List all entries
25//! for entry in reader.entries() {
26//!     println!("{}: {} bytes", entry.name, entry.uncompressed_size);
27//! }
28//!
29//! // Read a specific file
30//! let data = reader.read_entry_by_name("file.txt")?;
31//! # Ok::<(), s_zip::SZipError>(())
32//! ```
33//!
34//! ### Writing a ZIP file
35//!
36//! ```no_run
37//! use s_zip::StreamingZipWriter;
38//!
39//! let mut writer = StreamingZipWriter::new("output.zip")?;
40//!
41//! writer.start_entry("file1.txt")?;
42//! writer.write_data(b"Hello, World!")?;
43//!
44//! writer.start_entry("file2.txt")?;
45//! writer.write_data(b"Another file")?;
46//!
47//! writer.finish()?;
48//! # Ok::<(), s_zip::SZipError>(())
49//! ```
50//!
51//! ### Using arbitrary writers (in-memory, network, etc.)
52//!
53//! ```no_run
54//! use s_zip::StreamingZipWriter;
55//! use std::io::Cursor;
56//!
57//! // Write ZIP to in-memory buffer
58//! let buffer = Vec::new();
59//! let cursor = Cursor::new(buffer);
60//! let mut writer = StreamingZipWriter::from_writer(cursor)?;
61//!
62//! writer.start_entry("data.txt")?;
63//! writer.write_data(b"In-memory ZIP content")?;
64//!
65//! // finish() returns the writer, allowing you to extract the data
66//! let cursor = writer.finish()?;
67//! let zip_bytes = cursor.into_inner();
68//!
69//! println!("Created ZIP with {} bytes", zip_bytes.len());
70//! # Ok::<(), s_zip::SZipError>(())
71//! ```
72
73pub mod error;
74pub mod format;
75pub mod reader;
76pub mod writer;
77
78#[cfg(feature = "encryption")]
79pub mod encryption;
80
81#[cfg(feature = "encryption")]
82pub mod decrypt_reader;
83
84#[cfg(feature = "async")]
85pub mod async_writer;
86
87#[cfg(feature = "async")]
88pub mod async_reader;
89
90#[cfg(feature = "async")]
91pub mod parallel;
92
93#[cfg(any(feature = "cloud-s3", feature = "cloud-gcs"))]
94pub mod cloud;
95
96pub use error::{Result, SZipError};
97pub use format::ZipEntry;
98pub use reader::StreamingZipReader;
99pub use writer::{CompressionMethod, StreamingZipWriter};
100
101/// Options for a ZIP entry controlling metadata written to the local file header.
102///
103/// Use with `start_entry_with_options()` on either `StreamingZipWriter` or
104/// `AsyncStreamingZipWriter`. Fields default to "no metadata" (zero timestamp,
105/// no permissions).
106///
107/// # Example
108/// ```no_run
109/// # use s_zip::{StreamingZipWriter, EntryOptions};
110/// # use std::time::SystemTime;
111/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
112/// let mut writer = StreamingZipWriter::new("output.zip")?;
113/// let opts = EntryOptions {
114///     mtime: Some(SystemTime::now()),
115///     unix_mode: Some(0o644),
116/// };
117/// writer.start_entry_with_options("file.txt", opts)?;
118/// writer.write_data(b"Hello")?;
119/// writer.finish()?;
120/// # Ok(())
121/// # }
122/// ```
123#[derive(Debug, Clone, Default)]
124pub struct EntryOptions {
125    /// Last-modified time. Written as MS-DOS time/date in the local header.
126    /// If `None`, the timestamp fields are written as zero (no date).
127    pub mtime: Option<std::time::SystemTime>,
128    /// Unix file permission bits (e.g. `0o644`, `0o755`).
129    /// Written as a Unix extra field (ID 0x7875) in the local and central headers.
130    /// If `None`, no Unix extra field is written.
131    pub unix_mode: Option<u32>,
132}
133
134impl EntryOptions {
135    /// Convert `SystemTime` to MS-DOS date/time packed into a `u32`.
136    ///
137    /// MS-DOS time format (16-bit):
138    ///   bits 15-11: hours (0-23)
139    ///   bits 10-5:  minutes (0-59)
140    ///   bits 4-0:   seconds / 2 (0-29)
141    ///
142    /// MS-DOS date format (16-bit):
143    ///   bits 15-9: year - 1980 (0-127 → 1980-2107)
144    ///   bits 8-5:  month (1-12)
145    ///   bits 4-0:  day (1-31)
146    pub(crate) fn msdos_datetime(&self) -> (u16, u16) {
147        use std::time::{Duration, UNIX_EPOCH};
148
149        let Some(mtime) = self.mtime else {
150            return (0, 0);
151        };
152
153        // Seconds since Unix epoch; fall back to zero on out-of-range times
154        let secs = mtime
155            .duration_since(UNIX_EPOCH)
156            .unwrap_or(Duration::ZERO)
157            .as_secs();
158
159        // Convert Unix timestamp to calendar (simple Gregorian, no DST)
160        let secs_per_day = 86400u64;
161        let days_since_epoch = secs / secs_per_day;
162        let time_of_day = secs % secs_per_day;
163
164        let hour = (time_of_day / 3600) as u16;
165        let minute = ((time_of_day % 3600) / 60) as u16;
166        let second = (time_of_day % 60) as u16;
167
168        // Days since 1970-01-01 → calendar date (proleptic Gregorian)
169        let z = days_since_epoch as i64 + 719_468;
170        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
171        let doe = (z - era * 146_097) as u32;
172        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
173        let y = yoe as i64 + era * 400;
174        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
175        let mp = (5 * doy + 2) / 153;
176        let day = doy - (153 * mp + 2) / 5 + 1;
177        let month = if mp < 10 { mp + 3 } else { mp - 9 };
178        let year = if month <= 2 { y + 1 } else { y };
179
180        // Clamp to MS-DOS range (1980-2107)
181        let dos_year = (year.clamp(1980, 2107) - 1980) as u16;
182
183        let dos_time = (hour << 11) | (minute << 5) | (second / 2);
184        let dos_date = (dos_year << 9) | ((month as u16) << 5) | (day as u16);
185
186        (dos_time, dos_date)
187    }
188
189    /// Build the Unix extra field (ID 0x7875 "Info-ZIP New Unix") carrying uid=0, gid=0.
190    ///
191    /// Layout: header_id(2) + data_size(2) + version(1) + uid_size(1) + uid(N) + gid_size(1) + gid(N)
192    pub(crate) fn unix_extra_field(&self) -> Vec<u8> {
193        let Some(mode) = self.unix_mode else {
194            return Vec::new();
195        };
196
197        // Also write external file attributes carrying Unix mode in upper 16 bits —
198        // that's handled separately in the central dir. Here we write the extra field
199        // for readers that use it.
200        let _ = mode; // suppress unused warning — mode is used in central dir write
201
202        // 0x7875 "Info-ZIP New Unix" extra field: uid=0, gid=0 (minimal)
203        // version=1, uid_size=4, uid=0u32, gid_size=4, gid=0u32
204        let mut field = Vec::with_capacity(15);
205        field.extend_from_slice(&0x7875u16.to_le_bytes()); // ID
206        field.extend_from_slice(&11u16.to_le_bytes()); // data size
207        field.push(1); // version
208        field.push(4); // uid size
209        field.extend_from_slice(&0u32.to_le_bytes()); // uid = 0
210        field.push(4); // gid size
211        field.extend_from_slice(&0u32.to_le_bytes()); // gid = 0
212        field
213    }
214
215    /// Compute external file attributes from unix_mode for the central directory.
216    /// Returns 0 if no unix_mode is set.
217    #[allow(dead_code)]
218    pub(crate) fn external_attrs(&self) -> u32 {
219        self.unix_mode.map(|m| m << 16).unwrap_or(0)
220    }
221}
222
223#[cfg(feature = "async")]
224pub use async_writer::AsyncStreamingZipWriter;
225#[cfg(feature = "encryption")]
226pub use encryption::AesStrength;
227
228#[cfg(feature = "async")]
229pub use async_reader::{AsyncStreamingZipReader, GenericAsyncZipReader};
230
231#[cfg(feature = "async")]
232pub use parallel::{ParallelConfig, ParallelEntry};
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use std::io::Cursor;
238
239    #[test]
240    fn test_basic_write_read_roundtrip() {
241        // Create ZIP in memory
242        let buffer = Vec::new();
243        let cursor = Cursor::new(buffer);
244        let mut writer = StreamingZipWriter::from_writer(cursor).unwrap();
245
246        // Add first file
247        writer.start_entry("test1.txt").unwrap();
248        writer.write_data(b"Hello, World!").unwrap();
249
250        // Add second file
251        writer.start_entry("test2.txt").unwrap();
252        writer.write_data(b"Testing s-zip library").unwrap();
253
254        // Finish and get ZIP bytes
255        let cursor = writer.finish().unwrap();
256        let zip_bytes = cursor.into_inner();
257
258        // Verify ZIP was created
259        assert!(!zip_bytes.is_empty(), "ZIP should not be empty");
260
261        // Verify ZIP has correct signature
262        assert_eq!(
263            &zip_bytes[0..4],
264            b"PK\x03\x04",
265            "Should start with ZIP signature"
266        );
267    }
268
269    #[test]
270    fn test_compression_method_to_zip_method() {
271        assert_eq!(CompressionMethod::Stored.to_zip_method(), 0);
272        assert_eq!(CompressionMethod::Deflate.to_zip_method(), 8);
273
274        #[cfg(feature = "zstd-support")]
275        assert_eq!(CompressionMethod::Zstd.to_zip_method(), 93);
276    }
277
278    #[test]
279    fn test_empty_entry_name() {
280        let buffer = Vec::new();
281        let cursor = Cursor::new(buffer);
282        let mut writer = StreamingZipWriter::from_writer(cursor).unwrap();
283
284        // Try to create entry with empty name - should succeed (valid in ZIP spec)
285        assert!(writer.start_entry("").is_ok());
286    }
287
288    #[test]
289    fn test_multiple_small_entries() {
290        let buffer = Vec::new();
291        let cursor = Cursor::new(buffer);
292        let mut writer = StreamingZipWriter::from_writer(cursor).unwrap();
293
294        // Add 10 small files
295        for i in 0..10 {
296            let entry_name = format!("file_{}.txt", i);
297            let entry_data = format!("Content of file {}", i);
298
299            writer.start_entry(&entry_name).unwrap();
300            writer.write_data(entry_data.as_bytes()).unwrap();
301        }
302
303        let cursor = writer.finish().unwrap();
304        let zip_bytes = cursor.into_inner();
305
306        // Verify ZIP was created and has reasonable size
307        assert!(zip_bytes.len() > 100, "ZIP with 10 files should be larger");
308    }
309
310    #[test]
311    fn test_error_display() {
312        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
313        let err = SZipError::from(io_err);
314        assert!(format!("{}", err).contains("I/O error"));
315
316        let invalid_err = SZipError::InvalidFormat("bad format".to_string());
317        assert!(format!("{}", invalid_err).contains("Invalid ZIP format"));
318
319        let not_found_err = SZipError::EntryNotFound("missing.txt".to_string());
320        assert!(format!("{}", not_found_err).contains("Entry not found"));
321    }
322
323    #[cfg(feature = "encryption")]
324    #[test]
325    fn test_aes_strength() {
326        assert_eq!(AesStrength::Aes256.salt_size(), 16);
327        assert_eq!(AesStrength::Aes256.key_size(), 32);
328        assert_eq!(AesStrength::Aes256.to_winzip_code(), 0x03);
329    }
330}