diff --git a/src/write.rs b/src/write.rs index 02fb5db33..dc0ca7e79 100644 --- a/src/write.rs +++ b/src/write.rs @@ -1367,6 +1367,67 @@ impl ZipWriter { Ok(()) } + /// Removes an entry from the archive's directory without rewriting the + /// archive. + /// + /// The entry stops appearing in the archive: it is dropped from the central + /// directory written by [`ZipWriter::finish`], so readers no longer see it. + /// Its local header and file data are **left in place** as unreferenced + /// bytes, which is what makes this cheap — nothing after the removed entry + /// is moved or rewritten, so the cost does not scale with the size of the + /// archive. + /// + /// The file therefore does not shrink. Reclaiming the space needs a + /// rewrite, which a caller can do when it is worth doing (for example by + /// copying the surviving entries into a fresh archive with + /// [`ZipWriter::merge_archive`]) rather than on every removal. + /// + /// Any entry that shares data with the removed one — see + /// [`ZipWriter::shallow_copy_file`] — keeps working, because the bytes it + /// points at are untouched. + /// + /// If a file is currently being written, it is finished first, exactly as + /// [`ZipWriter::start_file`] does. + /// + /// # Safety + /// + ///.This function only deletes the file entry from the archive's central + /// directory. The file's content remains untouched. + /// + /// # Errors + /// + /// Returns [`ZipError::FileNotFound`] if `name` does not match an entry. + /// + /// ``` + /// # fn main() -> Result<(), zip::result::ZipError> { + /// use std::io::{Cursor, Write}; + /// use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions}; + /// + /// let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + /// zip.start_file("keep.txt", SimpleFileOptions::default())?; + /// zip.write_all(b"kept")?; + /// zip.start_file("drop.txt", SimpleFileOptions::default())?; + /// zip.write_all(b"dropped")?; + /// zip.soft_remove_file("drop.txt")?; + /// + /// let mut archive = ZipArchive::new(zip.finish()?)?; + /// assert_eq!(archive.len(), 1); + /// assert!(archive.by_name("keep.txt").is_ok()); + /// assert!(archive.by_name("drop.txt").is_err()); + /// # Ok(()) + /// # } + /// ``` + pub fn soft_remove_file(&mut self, name: &str) -> ZipResult<()> { + self.finish_file()?; + // `shift_remove` rather than `swap_remove`: the central directory is + // written in `files` order, and reordering the surviving entries on + // every removal would be a surprising side effect. + self.files + .shift_remove(name.as_bytes()) + .map(|_| ()) + .ok_or(ZipError::FileNotFound) + } + /// Removes the file currently being written from the archive if there is one, or else removes /// the file most recently written. pub fn abort_file(&mut self) -> ZipResult<()> { diff --git a/tests/remove_file.rs b/tests/remove_file.rs new file mode 100644 index 000000000..27e09f386 --- /dev/null +++ b/tests/remove_file.rs @@ -0,0 +1,209 @@ +//! Test about removing file + +use std::io::{Cursor, Write}; + +use zip::CompressionMethod; +use zip::ZipArchive; +use zip::ZipWriter; +use zip::result::{ZipError, ZipResult}; +use zip::write::SimpleFileOptions; + +#[test] +fn remove_file_drops_the_entry_but_keeps_the_others() -> ZipResult<()> { + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + for name in ["a.txt", "b.txt", "c.txt"] { + zip.start_file( + name, + SimpleFileOptions::default().compression_method(CompressionMethod::Stored), + )?; + zip.write_all(name.as_bytes())?; + } + zip.soft_remove_file("b.txt")?; + + let mut archive = ZipArchive::new(zip.finish()?)?; + assert_eq!(archive.len(), 2); + assert!(archive.by_name("b.txt").is_err()); + // The survivors still read back correctly: removing an entry must not + // disturb data written after it. + for name in ["a.txt", "c.txt"] { + let mut contents = String::new(); + std::io::Read::read_to_string(&mut archive.by_name(name)?, &mut contents)?; + assert_eq!(contents, name); + } + Ok(()) +} + +/// Central-directory order is `files` order, so a removal must not +/// reshuffle the entries around it. +#[test] +fn remove_file_preserves_the_order_of_surviving_entries() -> ZipResult<()> { + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + for name in ["a.txt", "b.txt", "c.txt", "d.txt"] { + zip.start_file( + name, + SimpleFileOptions::default().compression_method(CompressionMethod::Stored), + )?; + zip.write_all(b"x")?; + } + zip.soft_remove_file("b.txt")?; + + let archive = ZipArchive::new(zip.finish()?)?; + let names: Vec = (0..archive.len()) + .map(|i| archive.name_for_index(i).unwrap().unwrap().to_string()) + .collect(); + assert_eq!(names, ["a.txt", "c.txt", "d.txt"]); + Ok(()) +} + +/// The point of the method: the removed entry's bytes stay put, so the +/// cost of a removal does not scale with what follows it. +#[test] +fn remove_file_does_not_rewrite_the_archive() -> ZipResult<()> { + let big = vec![b'x'; 64 * 1024]; + // Stored, not deflated: the whole point is to observe the removed + // entry's bytes still occupying the file, and 64 KiB of one byte + // deflates to almost nothing. + let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + + let mut with_all = ZipWriter::new(Cursor::new(Vec::new())); + with_all.start_file("gone.txt", stored)?; + with_all.write_all(&big)?; + with_all.start_file("kept.txt", stored)?; + with_all.write_all(b"kept")?; + let removed_len = { + let mut zip = with_all; + zip.soft_remove_file("gone.txt")?; + zip.finish()?.into_inner().len() + }; + + let mut only_kept = ZipWriter::new(Cursor::new(Vec::new())); + only_kept.start_file("kept.txt", stored)?; + only_kept.write_all(b"kept")?; + let fresh_len = only_kept.finish()?.into_inner().len(); + + assert!( + removed_len > fresh_len + big.len() / 2, + "removed entry's bytes should still be present ({removed_len} vs {fresh_len})" + ); + Ok(()) +} + +/// A name freed by removal can be reused, which is what makes +/// replace-an-entry possible without rewriting. +#[test] +fn remove_file_frees_the_name_for_reuse() -> ZipResult<()> { + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file( + "a.txt", + SimpleFileOptions::default().compression_method(CompressionMethod::Stored), + )?; + zip.write_all(b"first")?; + zip.soft_remove_file("a.txt")?; + zip.start_file( + "a.txt", + SimpleFileOptions::default().compression_method(CompressionMethod::Stored), + )?; + zip.write_all(b"second")?; + + let mut archive = ZipArchive::new(zip.finish()?)?; + assert_eq!(archive.len(), 1); + let mut contents = String::new(); + std::io::Read::read_to_string(&mut archive.by_name("a.txt")?, &mut contents)?; + assert_eq!(contents, "second"); + Ok(()) +} + +/// Removing the entry currently being written finishes it first, so the +/// archive is left consistent rather than mid-entry. +#[test] +fn remove_file_can_remove_the_entry_being_written() -> ZipResult<()> { + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file( + "a.txt", + SimpleFileOptions::default().compression_method(CompressionMethod::Stored), + )?; + zip.write_all(b"a")?; + zip.start_file( + "b.txt", + SimpleFileOptions::default().compression_method(CompressionMethod::Stored), + )?; + zip.write_all(b"b")?; + zip.soft_remove_file("b.txt")?; + + let mut archive = ZipArchive::new(zip.finish()?)?; + assert_eq!(archive.len(), 1); + let mut contents = String::new(); + std::io::Read::read_to_string(&mut archive.by_name("a.txt")?, &mut contents)?; + assert_eq!(contents, "a"); + Ok(()) +} + +#[test] +fn remove_file_reports_a_missing_entry() { + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + assert!(matches!( + zip.soft_remove_file("absent.txt"), + Err(ZipError::FileNotFound) + )); +} + +/// An entry sharing data with the removed one keeps working: removal +/// touches the directory, never the bytes. +#[test] +fn remove_file_leaves_a_shallow_copy_readable() -> ZipResult<()> { + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file( + "original.txt", + SimpleFileOptions::default().compression_method(CompressionMethod::Stored), + )?; + zip.write_all(b"shared")?; + zip.shallow_copy_file("original.txt", "copy.txt")?; + zip.soft_remove_file("original.txt")?; + + let mut archive = ZipArchive::new(zip.finish()?)?; + assert_eq!(archive.len(), 1); + let mut contents = String::new(); + std::io::Read::read_to_string(&mut archive.by_name("copy.txt")?, &mut contents)?; + assert_eq!(contents, "shared"); + Ok(()) +} + +#[test] +fn remove_file_same_bytes() { + use zip::HasZipMetadata; + + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + for idx in 0..=10 { + let filename = format!("file_{idx}.txt"); + zip.start_file( + filename, + SimpleFileOptions::default().compression_method(CompressionMethod::Stored), + ) + .unwrap(); + zip.write_all(b"shared").unwrap(); + } + + // we have a basic zip file + let zip_raw = zip.finish_into_readable().unwrap().into_inner(); + let zip_raw_copy = zip_raw.clone().into_inner(); + + let (central_idx, zip_modified) = { + let mut zip = ZipArchive::new(zip_raw).unwrap(); + let central_idx = { + let file = zip.by_name("file_5.txt").unwrap(); + file.get_metadata().central_header_start as usize + }; + + let mut zip = ZipWriter::new_append(zip.into_inner()).unwrap(); + zip.soft_remove_file("file_5.txt").unwrap(); + + let readable = zip + .finish_into_readable() + .unwrap() + .into_inner() + .into_inner(); + (central_idx, readable) + }; + assert_eq!(central_idx, 787); + assert_eq!(zip_modified[0..central_idx], zip_raw_copy[0..central_idx]); +}