1use super::entry_options::EntryOptions;
2use super::entry_writer::EntryWriter;
3use super::helpers::CountWriter;
4use super::stored_entry::StoredEntry;
5
6use crate::deflate_encoder::DeflateEncoder;
7use crate::error::ZipError;
8use crate::header;
9
10use flate2::Compression;
11use std::borrow::Cow;
12use tokio::io::{AsyncWrite, AsyncWriteExt};
13
14pub struct ZipWriter<W: AsyncWrite + Unpin> {
39 pub(crate) inner: Option<W>,
40 pub(crate) entries: Vec<StoredEntry>,
41 level: Compression,
42 pub(crate) pos: u64,
43 pub(crate) poisoned: bool,
44}
45
46impl<W: AsyncWrite + Unpin> ZipWriter<W> {
47 pub fn new(inner: W) -> Self {
52 Self {
53 inner: Some(inner),
54 entries: Vec::new(),
55 level: Compression::default(),
56 pos: 0,
57 poisoned: false,
58 }
59 }
60
61 pub fn with_level(mut self, level: Compression) -> Self {
75 self.level = level;
76 self
77 }
78
79 pub async fn append_file<'a>(
106 &'a mut self,
107 name: &str,
108 options: EntryOptions,
109 ) -> Result<EntryWriter<'a, W>, ZipError> {
110 let mut inner = self.inner.take().ok_or_else(|| {
111 if self.poisoned {
112 ZipError::Poisoned("previous entry was dropped without calling close()".to_string())
113 } else {
114 ZipError::InvalidState("entry writer already active".to_string())
115 }
116 })?;
117
118 let name = sanitize_path(name, false);
119
120 let is_stored = self.level.level() == 0;
121 let method = if is_stored {
122 header::METHOD_STORED
123 } else {
124 header::METHOD_DEFLATE
125 };
126
127 let needs_zip64 = self.pos > header::U32_MAX;
128 let lfh = header::LocalFileHeader::new(&name, method, needs_zip64, options.mtime);
129 let lfh_bytes = lfh.serialize()?;
130 inner.write_all(&lfh_bytes).await?;
131 let offset = self.pos;
132 self.pos += lfh_bytes.len() as u64;
133
134 let (deflate_encoder, passthrough) = if is_stored {
135 (None, Some(CountWriter { inner, count: 0 }))
136 } else {
137 (
138 Some(DeflateEncoder::new(
139 CountWriter { inner, count: 0 },
140 self.level,
141 )),
142 None,
143 )
144 };
145
146 Ok(EntryWriter {
147 zip: self,
148 deflate_encoder,
149 passthrough,
150 is_stored,
151 crc_hasher: crc32fast::Hasher::new(),
152 uncompressed_size: 0,
153 local_header_offset: offset,
154 name: name.to_string(),
155 mtime: options.mtime,
156 unix_permissions: options.permissions,
157 uid_gid: options.uid_gid,
158 comment: options.comment.map(|s| s.into_bytes()),
159 })
160 }
161
162 pub async fn append_directory(
184 &mut self,
185 name: &str,
186 options: EntryOptions,
187 ) -> Result<(), ZipError> {
188 let mut inner = self.inner.take().ok_or_else(|| {
189 if self.poisoned {
190 ZipError::Poisoned("previous entry was dropped without calling close()".to_string())
191 } else {
192 ZipError::InvalidState("entry writer already active".to_string())
193 }
194 })?;
195
196 let name = sanitize_path(name, true);
197
198 let needs_zip64 = self.pos > header::U32_MAX;
199 let lfh =
200 header::LocalFileHeader::new(&name, header::METHOD_STORED, needs_zip64, options.mtime);
201 let lfh_bytes = lfh.serialize()?;
202 inner.write_all(&lfh_bytes).await?;
203 let offset = self.pos;
204 self.pos += lfh_bytes.len() as u64;
205
206 let dd = header::DataDescriptor {
207 crc32: 0,
208 compressed_size: 0,
209 uncompressed_size: 0,
210 zip64: offset > header::U32_MAX,
211 };
212 let dd_bytes = dd.serialize();
213 inner.write_all(&dd_bytes).await.map_err(|e| {
214 self.poisoned = true;
215 ZipError::Io(e)
216 })?;
217 self.pos += dd_bytes.len() as u64;
218
219 let (mtime_msdos, unix_mtime) = header::mtime_to_ms_dos_and_unix(options.mtime);
220
221 self.entries.push(StoredEntry {
222 name: name.to_string(),
223 crc32: 0,
224 compressed_size: 0,
225 uncompressed_size: 0,
226 local_header_offset: offset,
227 is_directory: true,
228 is_symlink: false,
229 is_stored: true,
230 mtime: mtime_msdos,
231 unix_mtime,
232 unix_permissions: options.permissions,
233 uid_gid: options.uid_gid,
234 comment: options.comment.map(|s| s.into_bytes()),
235 });
236
237 self.inner = Some(inner);
238 Ok(())
239 }
240
241 pub async fn append_symlink(
268 &mut self,
269 path: &str,
270 target: &str,
271 options: EntryOptions,
272 ) -> Result<(), ZipError> {
273 let name = sanitize_path(path, false);
274 let target = sanitize_path(target, false);
275 let mut inner = self.inner.take().ok_or_else(|| {
276 if self.poisoned {
277 ZipError::Poisoned("previous entry was dropped without calling close()".to_string())
278 } else {
279 ZipError::InvalidState("entry writer already active".to_string())
280 }
281 })?;
282 let needs_zip64 = self.pos > header::U32_MAX;
283 let lfh =
284 header::LocalFileHeader::new(&name, header::METHOD_STORED, needs_zip64, options.mtime);
285 let lfh_bytes = lfh.serialize()?;
286 inner.write_all(&lfh_bytes).await?;
287 let offset = self.pos;
288 self.pos += lfh_bytes.len() as u64;
289
290 let target_bytes = target.as_bytes();
292 inner.write_all(target_bytes).await?;
293 self.pos += target_bytes.len() as u64;
294
295 let mut hasher = crc32fast::Hasher::new();
297 hasher.update(target_bytes);
298 let crc32 = hasher.finalize();
299 let data_size = target_bytes.len() as u64;
300
301 let dd = header::DataDescriptor {
302 crc32,
303 compressed_size: data_size,
304 uncompressed_size: data_size,
305 zip64: data_size > header::U32_MAX || offset > header::U32_MAX,
306 };
307 let dd_bytes = dd.serialize();
308 inner.write_all(&dd_bytes).await.map_err(|e| {
309 self.poisoned = true;
310 ZipError::Io(e)
311 })?;
312 self.pos += dd_bytes.len() as u64;
313
314 let (mtime_msdos, unix_mtime) = header::mtime_to_ms_dos_and_unix(options.mtime);
315
316 self.entries.push(StoredEntry {
317 name: name.to_string(),
318 crc32,
319 compressed_size: data_size,
320 uncompressed_size: data_size,
321 local_header_offset: offset,
322 is_directory: false,
323 is_symlink: true,
324 is_stored: true,
325 mtime: mtime_msdos,
326 unix_mtime,
327 unix_permissions: options.permissions,
328 uid_gid: options.uid_gid,
329 comment: options.comment.map(|s| s.into_bytes()),
330 });
331 self.inner = Some(inner);
332 Ok(())
333 }
334
335 pub async fn finalize(mut self) -> Result<(), ZipError> {
350 let mut inner = self.inner.take().ok_or_else(|| {
351 if self.poisoned {
352 ZipError::Poisoned("previous entry was dropped without calling close()".to_string())
353 } else {
354 ZipError::InvalidState("entry writer still active".to_string())
355 }
356 })?;
357 let cd_offset = self.pos;
358
359 for entry in &self.entries {
360 let cd_entry = entry.to_central_dir_entry();
361 let data = cd_entry.serialize()?;
362 inner.write_all(&data).await?;
363 self.pos += data.len() as u64;
364 }
365
366 let cd_size = self.pos - cd_offset;
367 let total_entries = self.entries.len() as u64;
368 let needs_zip64 =
369 total_entries > 0xFFFF || cd_size > header::U32_MAX || cd_offset > header::U32_MAX;
370
371 if needs_zip64 {
372 let eocdr64 = header::Zip64Eocdr {
373 total_entries,
374 cd_size,
375 cd_offset,
376 };
377 let data = eocdr64.serialize();
378 let eocdr64_offset = self.pos;
379 inner.write_all(&data).await?;
380 self.pos += data.len() as u64;
381
382 let locator = header::Zip64EocdrLocator { eocdr64_offset };
383 inner.write_all(&locator.serialize()).await?;
384 self.pos += 20;
385 }
386
387 let eocdr = header::Eocdr {
388 total_entries,
389 cd_size,
390 cd_offset,
391 };
392 inner.write_all(&eocdr.serialize()).await?;
393 inner.shutdown().await?;
394 Ok(())
395 }
396}
397
398fn sanitize_path(name: &str, is_directory: bool) -> Cow<'_, str> {
399 let sanitized = if name.contains('\\') {
400 Cow::Owned(name.replace('\\', "/"))
401 } else {
402 Cow::Borrowed(name)
403 };
404 if is_directory && !sanitized.ends_with('/') {
405 Cow::Owned(format!("{sanitized}/"))
406 } else {
407 sanitized
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::writer::test_utils::lookup_entry;
415 use flate2::Compression;
416 use tokio::io::AsyncWriteExt;
417
418 #[tokio::test]
419 async fn test_zip_write_single_file() {
420 let mut buf = Vec::new();
421 let mut zip = ZipWriter::new(&mut buf);
422 let mut entry = zip
423 .append_file("hello.txt", EntryOptions::file())
424 .await
425 .unwrap();
426 entry.write_all(b"Hello, World!").await.unwrap();
427 entry.close().await.unwrap();
428 zip.finalize().await.unwrap();
429
430 assert!(buf.len() > 30);
431 assert!(buf.windows(4).any(|w| w == b"PK\x03\x04"));
432 assert!(buf.windows(4).any(|w| w == b"PK\x01\x02"));
433 assert!(buf.windows(4).any(|w| w == b"PK\x05\x06"));
434 }
435
436 #[tokio::test]
437 async fn test_zip_write_multiple_files() {
438 let mut buf = Vec::new();
439 let mut zip = ZipWriter::new(&mut buf);
440
441 let mut entry = zip
442 .append_file("a.txt", EntryOptions::file())
443 .await
444 .unwrap();
445 entry.write_all(b"aaa").await.unwrap();
446 entry.close().await.unwrap();
447
448 let mut entry = zip
449 .append_file("b.txt", EntryOptions::file())
450 .await
451 .unwrap();
452 entry.write_all(b"bbb").await.unwrap();
453 entry.close().await.unwrap();
454
455 zip.finalize().await.unwrap();
456 let cd_count = buf.windows(4).filter(|w| w == b"PK\x01\x02").count();
457 assert_eq!(cd_count, 2);
458 }
459
460 #[tokio::test]
461 async fn test_zip_compression_ratio() {
462 let mut buf = Vec::new();
463 let mut zip = ZipWriter::new(&mut buf).with_level(Compression::best());
464
465 let data = vec![b'A'; 1024];
466 let mut entry = zip
467 .append_file("repeated.txt", EntryOptions::file())
468 .await
469 .unwrap();
470 entry.write_all(&data).await.unwrap();
471 entry.close().await.unwrap();
472 zip.finalize().await.unwrap();
473
474 let entry = lookup_entry(&buf, 0);
475 assert!(
476 entry.compressed_size < entry.uncompressed_size,
477 "compressed {} >= uncompressed {}",
478 entry.compressed_size,
479 entry.uncompressed_size
480 );
481 }
482
483 #[tokio::test]
484 async fn test_symlink_entry() {
485 let mut buf = Vec::new();
486 let mut zip = ZipWriter::new(&mut buf);
487 zip.append_symlink("link.txt", "target.txt", EntryOptions::symlink())
488 .await
489 .unwrap();
490 zip.finalize().await.unwrap();
491
492 let pos = buf.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
493 let cd = &buf[pos..];
494
495 let vmb = u16::from_le_bytes(cd[4..6].try_into().unwrap());
496 assert_eq!(vmb >> 8, 3, "expected Unix host OS for symlink");
497
498 let version_needed = u16::from_le_bytes(cd[6..8].try_into().unwrap());
499 assert_eq!(version_needed, 10, "expected VERSION_STORED for symlink");
500
501 let method = u16::from_le_bytes(cd[10..12].try_into().unwrap());
502 assert_eq!(method, 0, "expected METHOD_STORED for symlink");
503
504 let efa = u32::from_le_bytes(cd[38..42].try_into().unwrap());
505 assert!(
506 (efa >> 16) & 0o170000 == 0o120000,
507 "expected S_IFLNK in external_file_attributes, got {:06o}",
508 efa >> 16
509 );
510
511 let lfh_pos = buf.windows(4).position(|w| w == b"PK\x03\x04").unwrap();
512 let lfh = &buf[lfh_pos..];
513 let lfh_name_len = u16::from_le_bytes(lfh[26..28].try_into().unwrap()) as usize;
514 let lfh_extra_len = u16::from_le_bytes(lfh[28..30].try_into().unwrap()) as usize;
515 let lfh_total = 30 + lfh_name_len + lfh_extra_len;
516 let data = &buf[lfh_pos + lfh_total..lfh_pos + lfh_total + 10];
517 assert_eq!(data, b"target.txt", "symlink target mismatch");
518 }
519
520 #[tokio::test]
521 async fn test_zip64_finalize_many_entries() {
522 let num_entries: u16 = 0xFFFF;
523 let mut buf = Vec::new();
524 let mut zip = ZipWriter::new(&mut buf).with_level(Compression::none());
525
526 for i in 0..=num_entries {
527 let name = format!("f{i}");
528 let mut entry = zip.append_file(&name, EntryOptions::file()).await.unwrap();
529 entry.write_all(b"x").await.unwrap();
530 entry.close().await.unwrap();
531 }
532
533 zip.finalize().await.unwrap();
534
535 let eocdr_pos = buf.windows(4).rposition(|w| w == b"PK\x05\x06").unwrap();
536 let eocdr_end = &buf[eocdr_pos..];
537 assert_eq!(
538 u16::from_le_bytes(eocdr_end[8..10].try_into().unwrap()),
539 0xFFFF,
540 "EOCDR total_entries should be sentinel 0xFFFF for ZIP64"
541 );
542
543 let locator_pos = buf.windows(4).rposition(|w| w == b"PK\x06\x07").unwrap();
544 assert_eq!(&buf[locator_pos..locator_pos + 4], b"PK\x06\x07");
545
546 let z64_pos = buf.windows(4).rposition(|w| w == b"PK\x06\x06").unwrap();
547 assert_eq!(&buf[z64_pos..z64_pos + 4], b"PK\x06\x06");
548
549 assert!(
550 z64_pos < locator_pos && locator_pos < eocdr_pos,
551 "expected Zip64Eocdr < Zip64EocdrLocator < Eocdr, got {z64_pos} < {locator_pos} < {eocdr_pos}"
552 );
553
554 let cd_count = buf.windows(4).filter(|w| w == b"PK\x01\x02").count();
555 assert_eq!(cd_count, num_entries as usize + 1);
556
557 assert_eq!(
559 &buf[42..46],
560 b"PK\x07\x08",
561 "first entry should have DD signature"
562 );
563 assert_eq!(
564 &buf[58..62],
565 b"PK\x03\x04",
566 "next LFH at offset 58 confirms 16-byte DD (non-ZIP64) for small-entry ZIP64 archive"
567 );
568 }
569
570 #[tokio::test]
571 async fn test_stored_entry_level_zero() {
572 let mut buf = Vec::new();
573 let mut zip = ZipWriter::new(&mut buf).with_level(Compression::none());
574
575 let data = b"Hello, stored entry!";
576 let mut entry = zip
577 .append_file("stored.txt", EntryOptions::file())
578 .await
579 .unwrap();
580 entry.write_all(data).await.unwrap();
581 entry.close().await.unwrap();
582 zip.finalize().await.unwrap();
583
584 let pos = buf.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
585 let cd = &buf[pos..];
586 let method = u16::from_le_bytes(cd[10..12].try_into().unwrap());
587 assert_eq!(method, 0, "expected METHOD_STORED for level=0 entry");
588 let version_needed = u16::from_le_bytes(cd[6..8].try_into().unwrap());
589 assert_eq!(
590 version_needed, 10,
591 "expected VERSION_STORED for level=0 entry"
592 );
593
594 let compressed_size = u32::from_le_bytes(cd[20..24].try_into().unwrap()) as u64;
595 let uncompressed_size = u32::from_le_bytes(cd[24..28].try_into().unwrap()) as u64;
596 assert_eq!(
597 compressed_size, uncompressed_size,
598 "stored entry should have equal compressed and uncompressed sizes"
599 );
600 assert_eq!(compressed_size, data.len() as u64);
601
602 let lfh_pos = buf.windows(4).position(|w| w == b"PK\x03\x04").unwrap();
603 let lfh_method = u16::from_le_bytes(buf[lfh_pos + 8..lfh_pos + 10].try_into().unwrap());
604 assert_eq!(lfh_method, 0, "LFH method should be STORED for level=0");
605 }
606
607 #[tokio::test]
608 async fn test_directory_entry() {
609 let mut buf = Vec::new();
610 let mut zip = ZipWriter::new(&mut buf);
611 zip.append_directory("mydir/", EntryOptions::directory())
612 .await
613 .unwrap();
614 zip.finalize().await.unwrap();
615
616 let pos = buf.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
617 let cd = &buf[pos..];
618
619 let version_needed = u16::from_le_bytes(cd[6..8].try_into().unwrap());
620 assert_eq!(version_needed, 10, "expected VERSION_STORED for directory");
621
622 let method = u16::from_le_bytes(cd[10..12].try_into().unwrap());
623 assert_eq!(method, 0, "expected METHOD_STORED for directory");
624
625 let efa = u32::from_le_bytes(cd[38..42].try_into().unwrap());
626 assert!(
627 (efa >> 16) & 0o170000 == 0o040000,
628 "expected S_IFDIR in external_file_attributes, got {:06o}",
629 efa >> 16
630 );
631 assert_eq!(
632 (efa >> 16) & 0o7777,
633 0o755,
634 "expected directory permissions 0o755, got {:06o}",
635 (efa >> 16) & 0o7777
636 );
637
638 let name_len = u16::from_le_bytes(cd[28..30].try_into().unwrap()) as usize;
639 let extra_len = u16::from_le_bytes(cd[30..32].try_into().unwrap()) as usize;
640 let extra_start = 46 + name_len;
641 let extra = &cd[extra_start..extra_start + extra_len];
642 assert!(
643 extra.windows(2).any(|w| w == b"UT"),
644 "CD extra should contain UT (0x5455) tag for directory with mtime"
645 );
646 }
647
648 #[tokio::test]
649 async fn test_file_entry_comment() {
650 let mut buf = Vec::new();
651 let mut zip = ZipWriter::new(&mut buf);
652 let mut entry = zip
653 .append_file(
654 "commented.txt",
655 EntryOptions {
656 mtime: std::time::SystemTime::now(),
657 permissions: None,
658 uid_gid: None,
659 comment: Some("file comment".to_string()),
660 },
661 )
662 .await
663 .unwrap();
664 entry.write_all(b"data").await.unwrap();
665 entry.close().await.unwrap();
666 zip.finalize().await.unwrap();
667
668 let pos = buf.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
669 let cd = &buf[pos..];
670
671 let name_len = u16::from_le_bytes(cd[28..30].try_into().unwrap()) as usize;
672 let extra_len = u16::from_le_bytes(cd[30..32].try_into().unwrap()) as usize;
673 let comment_len = u16::from_le_bytes(cd[32..34].try_into().unwrap()) as usize;
674 assert_eq!(comment_len, 12, "expected 12-byte comment");
675
676 let comment_start = 46 + name_len + extra_len;
677 let comment = &cd[comment_start..comment_start + comment_len];
678 assert_eq!(comment, b"file comment");
679 }
680
681 #[tokio::test]
682 async fn test_directory_entry_comment() {
683 let mut buf = Vec::new();
684 let mut zip = ZipWriter::new(&mut buf);
685 zip.append_directory(
686 "dir/",
687 EntryOptions {
688 mtime: std::time::SystemTime::now(),
689 permissions: Some(0o755),
690 uid_gid: None,
691 comment: Some("dir comment".to_string()),
692 },
693 )
694 .await
695 .unwrap();
696 zip.finalize().await.unwrap();
697
698 let pos = buf.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
699 let cd = &buf[pos..];
700
701 let name_len = u16::from_le_bytes(cd[28..30].try_into().unwrap()) as usize;
702 let extra_len = u16::from_le_bytes(cd[30..32].try_into().unwrap()) as usize;
703 let comment_len = u16::from_le_bytes(cd[32..34].try_into().unwrap()) as usize;
704 assert_eq!(comment_len, 11, "expected 11-byte comment");
705
706 let comment_start = 46 + name_len + extra_len;
707 let comment = &cd[comment_start..comment_start + comment_len];
708 assert_eq!(comment, b"dir comment");
709 }
710}