Skip to main content

async_deflate_zip/writer/
zip_writer.rs

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
14/// A streaming ZIP archive writer with per-file deflate compression.
15///
16/// Entries are written sequentially — each file produces its own deflate
17/// frame with a data descriptor (CRC-32 and sizes) after each entry. The output is a
18/// standard ZIP archive compatible with common unzip tools, including
19/// Windows Explorer.
20///
21/// # Example
22///
23/// ```rust,no_run
24/// use async_deflate_zip::{ZipWriter, EntryOptions};
25/// use tokio::io::AsyncWriteExt;
26///
27/// # async fn example() {
28/// let mut buf = Vec::new();
29/// let mut zip = ZipWriter::new(&mut buf);
30///
31/// let mut entry = zip.append_file("hello.txt", EntryOptions::file()).await.unwrap();
32/// entry.write_all(b"Hello, World!").await.unwrap();
33/// entry.close().await.unwrap();
34///
35/// zip.finalize().await.unwrap();
36/// # }
37/// ```
38pub 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    /// Create a new `ZipWriter` wrapping an async writer.
48    ///
49    /// Uses the default compression level ([`Compression::default`], level 6).
50    /// Use [`with_level`](Self::with_level) to customize.
51    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    /// Set the compression level for entries added to this archive.
62    ///
63    /// Must be called before adding any entries. Returns `self` for chaining.
64    ///
65    /// # Example
66    ///
67    /// ```rust,no_run
68    /// use async_deflate_zip::{ZipWriter, Compression};
69    ///
70    /// let mut buf = Vec::new();
71    /// let zip = ZipWriter::new(&mut buf)
72    ///     .with_level(Compression::best());
73    /// ```
74    pub fn with_level(mut self, level: Compression) -> Self {
75        self.level = level;
76        self
77    }
78
79    /// Start a new file entry and return an [`EntryWriter`] for streaming data.
80    ///
81    /// Writes the Local File Header, then returns an `EntryWriter` that
82    /// compresses and buffers written data. Call [`EntryWriter::close`]
83    /// to finalize the entry and write the trailing CRC-32 and sizes.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`ZipError`] if writer is poisoned, or if writing the
88    /// Local File Header fails (I/O error or field too long).
89    ///
90    /// # Example
91    ///
92    /// ```rust,no_run
93    /// use async_deflate_zip::{ZipWriter, EntryOptions};
94    /// use tokio::io::AsyncWriteExt;
95    ///
96    /// # async fn example() {
97    /// let mut buf = Vec::new();
98    /// let mut zip = ZipWriter::new(&mut buf);
99    /// let mut entry = zip.append_file("readme.txt", EntryOptions::file()).await.unwrap();
100    /// entry.write_all(b"content").await.unwrap();
101    /// entry.close().await.unwrap();
102    /// zip.finalize().await.unwrap();
103    /// # }
104    /// ```
105    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    /// Start a new directory entry.
163    ///
164    /// Writes the Local File Header and Data Descriptor, then registers
165    /// the entry in the archive. Directory names should end with `'/'`.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`ZipError`] if writer is poisoned, or if writing fails.
170    ///
171    /// # Example
172    ///
173    /// ```rust,no_run
174    /// use async_deflate_zip::{ZipWriter, EntryOptions};
175    ///
176    /// # async fn example() {
177    /// let mut buf = Vec::new();
178    /// let mut zip = ZipWriter::new(&mut buf);
179    /// zip.append_directory("mydir/", EntryOptions::directory()).await.unwrap();
180    /// zip.finalize().await.unwrap();
181    /// # }
182    /// ```
183    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    /// Add a symbolic link entry.
242    ///
243    /// The `name` is the path of the symlink, and `target` is the path
244    /// the symlink points to. The target is stored uncompressed as the
245    /// entry's data content. The Central Directory entry uses `S_IFLNK`
246    /// with `VERSION_UNIX` so Unix unzip tools correctly restore the
247    /// symlink.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`ZipError`] if writer is poisoned, or if writing the
252    /// Local File Header, symlink target, or Data Descriptor fails (I/O error
253    /// or field too long).
254    ///
255    /// # Example
256    ///
257    /// ```rust,no_run
258    /// use async_deflate_zip::{ZipWriter, EntryOptions};
259    ///
260    /// # async fn example() {
261    /// let mut buf = Vec::new();
262    /// let mut zip = ZipWriter::new(&mut buf);
263    /// zip.append_symlink("link.txt", "target.txt", EntryOptions::symlink()).await.unwrap();
264    /// zip.finalize().await.unwrap();
265    /// # }
266    /// ```
267    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        // Write the symlink target as stored (uncompressed) data
291        let target_bytes = target.as_bytes();
292        inner.write_all(target_bytes).await?;
293        self.pos += target_bytes.len() as u64;
294
295        // CRC-32 of the target path
296        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    /// Finalize the archive by writing the Central Directory and EOCDR.
336    ///
337    /// This writes the Central Directory entries for all file and directory
338    /// entries, followed by the End of Central Directory Record (and ZIP64
339    /// records if needed). The inner writer is flushed and shut down.
340    ///
341    /// After calling `finalize`, the `ZipWriter` is consumed and cannot be
342    /// used to add more entries.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`ZipError`] if an entry writer is still active or the writer is
347    /// poisoned, if writing the Central Directory or EOCDR fails (I/O error or
348    /// field too long), or if the inner writer's shutdown fails.
349    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        // LFH (30 + 2 name + 9 extra = 41) + 1 byte data + 16 DD = 58
558        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}