diff --git a/src/read.rs b/src/read.rs index 75803ab1e..332b3ee32 100644 --- a/src/read.rs +++ b/src/read.rs @@ -1827,6 +1827,12 @@ impl<'a, R: Read> ZipFile<'a, R> { ); options.normalize(); + #[cfg(feature = "aes-crypto")] + if let Some(aes) = self.get_metadata().aes_mode { + // Preserve AES metadata in options for downstream writers. + // This is metadata-only and does not trigger encryption. + options.aes_mode = Some(aes); + } options } } diff --git a/src/types.rs b/src/types.rs index 0b615e60d..8afda30d4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -710,7 +710,16 @@ impl ZipFileData { let mut local_block = ZipFileData { system: System::Unix, version_made_by: DEFAULT_VERSION, - encrypted: options.encrypt_with.is_some(), + encrypted: options.encrypt_with.is_some() || { + #[cfg(feature = "aes-crypto")] + { + options.aes_mode.is_some() + } + #[cfg(not(feature = "aes-crypto"))] + { + false + } + }, using_data_descriptor: false, is_utf8: !file_name.is_ascii(), compression_method, @@ -1213,7 +1222,7 @@ impl FixedSizeBlock for Zip64DataDescriptorBlock { /// /// According to the [specification](https://www.winzip.com/win/en/aes_info.html#winzip11) AE-2 /// does not make use of the CRC check. -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u16)] pub enum AesVendorVersion { Ae1 = 0x0001, diff --git a/src/write.rs b/src/write.rs index f7d8cee8a..8ec443f24 100644 --- a/src/write.rs +++ b/src/write.rs @@ -273,6 +273,8 @@ pub struct FileOptions<'k, T: FileOptionExtension> { pub(crate) alignment: u16, #[cfg(feature = "deflate-zopfli")] pub(super) zopfli_buffer_size: Option, + #[cfg(feature = "aes-crypto")] + pub(crate) aes_mode: Option<(AesMode, AesVendorVersion, CompressionMethod)>, } /// Simple File Options. Can be copied and good for simple writing zip files pub type SimpleFileOptions = FileOptions<'static, ()>; @@ -566,6 +568,8 @@ impl Default for FileOptions<'_, T> { alignment: 1, #[cfg(feature = "deflate-zopfli")] zopfli_buffer_size: Some(1 << 15), + #[cfg(feature = "aes-crypto")] + aes_mode: None, } } } @@ -932,9 +936,25 @@ impl ZipWriter { 0x9901, aes_dummy_extra_data, )?; + } else if let Some((mode, vendor, underlying)) = options.aes_mode { + // For raw copies of AES entries, write the correct AES extra data immediately + let mut body = Vec::with_capacity(7); + body.write_u16_le(vendor as u16)?; // vendor version (1 or 2) + body.extend_from_slice(b"AE"); // vendor id + body.push(mode as u8); // strength + body.write_u16_le(underlying.serialize_to_u16())?; // real compression method + aes_extra_data_start = extra_data.len() as u64; + ExtendedFileOptions::add_extra_data_unchecked( + &mut extra_data, + 0x9901, + body.into_boxed_slice(), + )?; } let (compression_method, aes_mode) = match options.encrypt_with { + // Preserve AES method for raw copies without needing a password + #[cfg(feature = "aes-crypto")] + None if options.aes_mode.is_some() => (CompressionMethod::Aes, options.aes_mode), #[cfg(feature = "aes-crypto")] Some(EncryptWith::Aes { mode, .. }) => ( CompressionMethod::Aes, @@ -2317,6 +2337,8 @@ mod test { alignment: 1, #[cfg(feature = "deflate-zopfli")] zopfli_buffer_size: None, + #[cfg(feature = "aes-crypto")] + aes_mode: None, }; writer.start_file("mimetype", options).unwrap(); writer @@ -2358,6 +2380,8 @@ mod test { alignment: 1, #[cfg(feature = "deflate-zopfli")] zopfli_buffer_size: None, + #[cfg(feature = "aes-crypto")] + aes_mode: None, }; // GB18030 @@ -2411,6 +2435,8 @@ mod test { alignment: 0, #[cfg(feature = "deflate-zopfli")] zopfli_buffer_size: None, + #[cfg(feature = "aes-crypto")] + aes_mode: None, }; writer.start_file(RT_TEST_FILENAME, options).unwrap(); writer.write_all(RT_TEST_TEXT.as_ref()).unwrap(); @@ -2462,6 +2488,8 @@ mod test { alignment: 0, #[cfg(feature = "deflate-zopfli")] zopfli_buffer_size: None, + #[cfg(feature = "aes-crypto")] + aes_mode: None, }; writer.start_file(RT_TEST_FILENAME, options).unwrap(); writer.write_all(RT_TEST_TEXT.as_ref()).unwrap(); diff --git a/tests/aes_encryption.rs b/tests/aes_encryption.rs index c135914d0..02261fcd7 100644 --- a/tests/aes_encryption.rs +++ b/tests/aes_encryption.rs @@ -1,6 +1,7 @@ #![cfg(feature = "aes-crypto")] use std::io::{self, Read, Write}; +use zip::write::ZipWriter; use zip::{result::ZipError, write::SimpleFileOptions, AesMode, CompressionMethod, ZipArchive}; const SECRET_CONTENT: &str = "Lorem ipsum dolor sit amet"; @@ -152,3 +153,85 @@ fn test_extract_encrypted_file( assert_eq!(SECRET_CONTENT, content); } } + +#[test] +fn raw_copy_from_aes_zip() { + let mut v = Vec::new(); + v.extend_from_slice(include_bytes!("data/aes_archive.zip")); + + let dst_cursor = { + let mut src = + ZipArchive::new(io::Cursor::new(v.as_slice())).expect("couldn't open source zip"); + let mut dst = ZipWriter::new(io::Cursor::new(Vec::new())); + + let total = src.len(); + for i in 0..total { + let file = src.by_index_raw(i).expect("read source entry"); + let name = file.name().to_string(); + if file.is_dir() { + dst.add_directory(&name, SimpleFileOptions::default()) + .expect("add directory"); + } else { + dst.raw_copy_file(file).expect("raw copy file"); + } + } + dst.finish().expect("finish dst") + }; + + let mut src_zip = ZipArchive::new(io::Cursor::new(v.as_slice())).expect("reopen src zip"); + let mut dst_zip = ZipArchive::new(dst_cursor).expect("reopen dst zip"); + + let total = src_zip.len(); + + for i in 0..total { + // Copy out simple header fields without holding borrows across later reads + let (name, is_dir, s_encrypted, d_encrypted, s_comp, d_comp) = { + let s = src_zip.by_index_raw(i).expect("src by_index_raw"); + let name = s.name().to_string(); + let is_dir = s.is_dir(); + let s_encrypted = s.encrypted(); + let s_comp = s.compression(); + let d = dst_zip.by_index_raw(i).expect("dst by_index_raw"); + let d_encrypted = d.encrypted(); + let d_comp = d.compression(); + (name, is_dir, s_encrypted, d_encrypted, s_comp, d_comp) + }; + + // AES-critical invariants preserved by raw copy + assert_eq!( + s_encrypted, d_encrypted, + "encrypted flag differs for {name}" + ); + assert_eq!(s_comp, d_comp, "compression method differs for {name}"); + + // For files, verify content bytes match. For encrypted entries, use the shared fixture password + if !is_dir { + let mut s_buf = Vec::new(); + let mut d_buf = Vec::new(); + if s_encrypted { + src_zip + .by_index_decrypt(i, PASSWORD) + .expect("decrypt src") + .read_to_end(&mut s_buf) + .expect("read src"); + dst_zip + .by_index_decrypt(i, PASSWORD) + .expect("decrypt dst") + .read_to_end(&mut d_buf) + .expect("read dst"); + } else { + src_zip + .by_index(i) + .expect("open src") + .read_to_end(&mut s_buf) + .expect("read src"); + dst_zip + .by_index(i) + .expect("open dst") + .read_to_end(&mut d_buf) + .expect("read dst"); + } + assert_eq!(s_buf, d_buf, "content differs for {name}"); + } + } +}