Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,67 @@ impl<W: Write + Seek> ZipWriter<W> {
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<()> {
Expand Down
209 changes: 209 additions & 0 deletions tests/remove_file.rs
Original file line number Diff line number Diff line change
@@ -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<String> = (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]);
}