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}