Working with ZIP Archives

ZIP archives often arrive at a trust boundary: a file name can become a filesystem path, and a small compressed item can expand into a large output. The ZIP API keeps those concerns visible through separate reader and writer objects, immutable item metadata, bounded extraction, and an explicit finalization step. This page shows how to inspect, safely extract, create, filter, and reassemble archives.

Inspecting an Archive

Open a ArchiveReader from a path, byte block, or owned input stream. An owned stream must support positioning. Before the factory returns, the reader loads and validates only the bounded archive tail and central directory; local headers, descriptors, and payloads are deferred until the corresponding item is accessed. An out-of-range item() lookup returns a null pointer, while items() gives you a convenient snapshot for iteration.

Each ArchiveItem carries a normalized relative path, an explicit file/directory distinction, compression method, modification time, CRC-32, lengths, and comment. Metadata remains useful after the reader closes, but payload access requires the reader because an item deliberately keeps only a weak link to it. Archive-level memory use is independent of the total file length, while each selected compressed and uncompressed item is transferred in bounded chunks; whole-value extraction still returns an in-memory value.

compression/ZipArchive/ReadArchive.cpp

/// Inspect validated ZIP metadata and extract selected entries under a safe root.
void readArchive() {
    const auto workspace = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
    const auto archivePath = workspace->path() / "survey.zip"_el;
    const auto northText = el::String{"north"_el};
    const auto southText = el::String{"south"_el};
    auto writer = el::zip::ArchiveWriter::create(archivePath);
    writer->addData(
        el::StringEncoder{northText}.encode(el::StringEncoding::Utf8), el::Path{"measurements/north.txt"_el});
    writer->addData(
        el::StringEncoder{southText}.encode(el::StringEncoding::Utf8), el::Path{"measurements/south.txt"_el});
    writer->finalize();

    const auto outputRoot = workspace->path() / "selected"_el;
    auto reader = el::zip::ArchiveReader::create(archivePath);
    for (const auto &item : reader->items()) {
        el::io::printLine(item->path().toString(), " : "_el, item->uncompressedLength().toSizeT(), " bytes"_el);
    }
    reader->extractToDirectory(
        outputRoot, {}, [](const el::zip::ArchiveItem &item) { return item.path().name() == "north.txt"_el; });
    reader->close();

    el::io::printLine("Extracted under: "_el, outputRoot.toString());
}

Output

measurements/north.txt : 5 bytes
measurements/south.txt : 5 bytes
Extracted under: /var/folders/f2/rq38shx1797_b7m0vy142phw0000gn/T/tmp-74W2zYT7Ijdut52gN7iB
zhxb/selected

Safe Extraction

extractToDirectory() combines the extraction root with each already-normalized relative item path. The default collision mode is Stop; choose Skip or Overwrite only when that behavior is part of your application’s policy. Files are decoded and validated before an atomic sibling temporary file replaces the destination, and modification times are preserved by default. Existing symbolic links and reparse points below the extraction root are rejected. During bulk extraction, explicit directories are prepared before files and their timestamps are restored after their selected children have been written.

The reader applies a 1 GiB default total-output limit to bulk extraction and a 256 MiB default limit to individual items. Tune the archive-wide bounds through ArchiveReaderOptions and the per-operation limit through ArchiveExtractionOptions. Declared output sizes are checked before decompression; the actual decoded size and CRC-32 are checked again before content is committed.

You can filter bulk extraction with a callback without exposing archive mutation:

reader->extractToDirectory(outputRoot, {}, [](const zip::ArchiveItem &item) {
    return !item.isDirectory() && item.path().suffix() == ".json"_el;
});

Creating an Archive

An ArchiveWriter writes only a new archive. Set persistent defaults once, then add paths, byte blocks, owned input streams, or a directory tree. Per-entry options override the compression method, level, timestamp, or comment without changing later entries.

compression/ZipArchive/WriteArchive.cpp

/// Create and finalize a ZIP archive from bytes using persistent compression defaults.
void writeArchive() {
    const auto workspace = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
    const auto archivePath = workspace->path() / "field-notes.zip"_el;
    const auto notesText = el::String{"alpine observation"_el};
    const auto notes = el::StringEncoder{notesText}.encode(el::StringEncoding::Utf8);

    auto writer = el::zip::ArchiveWriter::create(archivePath);
    writer->setCompressionMethod(el::zip::CompressionMethod::Zstandard);
    writer->setCompressionLevel(el::CompressionLevel::High);
    writer->setComment("Expedition field notes"_el);
    writer->addData(notes, el::Path{"observations/day-01.txt"_el});
    writer->finalize();

    el::io::printLine("Archive : "_el, archivePath.toString());
    el::io::printLine("Bytes   : "_el, archivePath.info().fileSize().toSizeT());
}

Output

Archive : /var/folders/f2/rq38shx1797_b7m0vy142phw0000gn/T/tmp-74W2zYT7Ijdut52gN7iBzhxb/fi
eld-notes.zip
Bytes   : 231

finalize() is required because it writes the central directory and closes the owned output stream. Repeating it after success is harmless, while every add operation then raises LogicFailure. abort() is the immediate terminal alternative. For path destinations, the old file is not replaced until the new archive is complete.

Directory Filtering

ArchiveDirectoryOptions controls recursive walking, optional inclusion of the source directory itself, and include/exclude globs. Patterns match normalized, case-sensitive paths: * and ? stay within one component, while ** may cross slashes. A metadata callback can make decisions that a name pattern cannot, and directory traversal never follows links.

ZIP64 Policy

Zip64Policy::Automatic is the normal choice. It writes classic records while their fields fit and substitutes ZIP64 fields when an item, offset, directory, or entry count grows beyond a classic limit. Always makes small ZIP64 fixtures for interoperability tests. Never provides an explicit classic-only contract and fails before writing a record that requires a ZIP64 field.

Errors and Limits

Invalid caller parameters and lifecycle misuse raise ParameterFailure or LogicFailure. Archive, stream, path, codec, integrity, and resource-policy failures raise ZipError. Its context includes a concise title, detailed description, machine-readable reason and operation phase, relevant source/destination/item paths, and the retained underlying exception where one exists.

Reassembling Without Recompression

Use addItem() when an accepted entry should move from one open reader to a new writer without spending compression work again. The source reader validates and loads only that item’s local record and compressed payload. The writer copies the compressed payload and opaque extra fields unchanged, then regenerates managed path, UTF-8, timestamp, ZIP64, and header fields. This operation intentionally does not decompress the payload; ordinary extraction remains the point where CRC-32 and uncompressed-length integrity are verified.

auto source = zip::ArchiveReader::create(sourcePath);
auto destination = zip::ArchiveWriter::create(destinationPath);
for (const auto &item : source->items()) {
    destination->addItem(*item);
}
destination->finalize();

Complete demo sources

compression/ZipArchive/ReadArchive.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathOperations.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringEncoder.hpp>
#include <erbsland/StringEncoding.hpp>
#include <erbsland/TempDirectory.hpp>
#include <erbsland/zip/ArchiveItem.hpp>
#include <erbsland/zip/ArchiveReader.hpp>
#include <erbsland/zip/ArchiveWriter.hpp>

namespace demo {

/// Inspect validated ZIP metadata and extract selected entries under a safe root.
void readArchive() {
    const auto workspace = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
    const auto archivePath = workspace->path() / "survey.zip"_el;
    const auto northText = el::String{"north"_el};
    const auto southText = el::String{"south"_el};
    auto writer = el::zip::ArchiveWriter::create(archivePath);
    writer->addData(
        el::StringEncoder{northText}.encode(el::StringEncoding::Utf8), el::Path{"measurements/north.txt"_el});
    writer->addData(
        el::StringEncoder{southText}.encode(el::StringEncoding::Utf8), el::Path{"measurements/south.txt"_el});
    writer->finalize();

    const auto outputRoot = workspace->path() / "selected"_el;
    auto reader = el::zip::ArchiveReader::create(archivePath);
    for (const auto &item : reader->items()) {
        el::io::printLine(item->path().toString(), " : "_el, item->uncompressedLength().toSizeT(), " bytes"_el);
    }
    reader->extractToDirectory(
        outputRoot, {}, [](const el::zip::ArchiveItem &item) { return item.path().name() == "north.txt"_el; });
    reader->close();

    el::io::printLine("Extracted under: "_el, outputRoot.toString());
}

}

compression/ZipArchive/WriteArchive.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/CompressionLevel.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathInfo.hpp>
#include <erbsland/PathOperations.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringEncoder.hpp>
#include <erbsland/StringEncoding.hpp>
#include <erbsland/TempDirectory.hpp>
#include <erbsland/zip/ArchiveWriter.hpp>
#include <erbsland/zip/CompressionMethod.hpp>

namespace demo {

/// Create and finalize a ZIP archive from bytes using persistent compression defaults.
void writeArchive() {
    const auto workspace = el::Path::systemTempDirectoryOrThrow().operations().createTempDirectoryOrThrow();
    const auto archivePath = workspace->path() / "field-notes.zip"_el;
    const auto notesText = el::String{"alpine observation"_el};
    const auto notes = el::StringEncoder{notesText}.encode(el::StringEncoding::Utf8);

    auto writer = el::zip::ArchiveWriter::create(archivePath);
    writer->setCompressionMethod(el::zip::CompressionMethod::Zstandard);
    writer->setCompressionLevel(el::CompressionLevel::High);
    writer->setComment("Expedition field notes"_el);
    writer->addData(notes, el::Path{"observations/day-01.txt"_el});
    writer->finalize();

    el::io::printLine("Archive : "_el, archivePath.toString());
    el::io::printLine("Bytes   : "_el, archivePath.info().fileSize().toSizeT());
}

}