8.3.101-stable Switch to dev

GreyCat Archive Library

@library("archive", "0.0.0");

Read, write, inspect and validate archives from GreyCat. Handles zip, tar (plain or compressed), 7z, and bare compressed files such as data.csv.gz. Every operation works on paths on disk — there are no in-memory buffers in the API — and the format is always auto-detected from file content on read, never from the extension.

Quick Start

// Is this file actually an intact archive?
var check = Archive::verify("uploads/batch.csv.gz");
if (!check.ok) {
    throw "rejected: ${check.error}";
}
info("${check.format} / ${check.compression}, ${check.size} bytes uncompressed");

// Peek at the first line without decompressing the rest
var header = Archive::head("uploads/batch.csv.gz", 1, null).get(0);
info("columns: ${header}");

// List entries without extracting
for (_, entry in Archive::list("release.zip")) {
    info("${entry.path} (${entry.size} bytes)");
}

// Extract everything
Archive::extract("release.zip", "out/");

// Create a new archive
Archive::create("backup.tar.gz", ArchiveFormat::tar_gz, Array<String> { "data/", "config.json" });

Supported Formats

Containers

Format Read Write ArchiveFormat
zip zip
tar tar, tar_gz, tar_xz, tar_zst
7z sevenzip
none (bare compressed file) gzip

Also recognized on read, but not writable: cpio, ar, cab, iso9660, lha, rar, rar5, warc, xar, mtree.

Compression

Filter Read Write Notes
gzip built in
xz / lzma built in
zstd built in
bzip2 requires a bzip2 binary on PATH — not linked in
none

Compression is detected independently of the container, so .tar.gz, .tar.zst and a bare .csv.xz all read through the same calls.

bzip2 caveat: libarchive falls back to spawning an external bzip2 -d process for this filter. Where that binary is missing, reads fail with unable to run program "bzip2 -d". Every other filter above is linked into the library and needs nothing installed.

API Reference

Archive

Method Returns Description
list(archive_path) Array<ArchiveEntry> Every entry, without extracting
extract(archive_path, dest_dir) void Extract all entries into a directory
verify(archive_path) ArchiveCheck Full integrity check; never raises
head(archive_path, lines, entry) Array<String> First N lines of one entry
create(archive_path, format, sources) void Write a new archive
create_from(archive_path, format, sources, base_dir) void Write a new archive with entry names relative to base_dir

list(archive_path)

Reads headers only. A bare compressed file yields a single entry named "data". Raises if the file is not an archive.

var entries = Archive::list("data/export.tar.gz");
for (_, e in entries) {
    if (e.kind == ArchiveEntryKind::file) {
        info("${e.path}: ${e.size} bytes, modified ${e.modified}");
    }
}

extract(archive_path, dest_dir)

Extracts every entry, preserving the stored directory structure. dest_dir is created if missing. A bare compressed file extracts to dest_dir/data. If the input is not an archive it raises and writes nothing — it will not copy the input to dest_dir/data.

Archive::extract("release.zip", "unpacked/");

verify(archive_path)

Decompresses the entire stream and discards the bytes, checking integrity as it goes. Never raises — a missing file, a CSV, a truncated zip and a corrupt .gz all come back as ok: false with a reason.

var c = Archive::verify("uploads/incoming.tar.gz");
if (c.ok) {
    info("intact, ${c.size} bytes uncompressed");
} else {
    warn("bad archive: ${c.error}");
}

This is a strictly stronger check than list, which only reads headers:

Input list verify
valid archive entries ok: true
not an archive raises ok: false
zip with a bad entry CRC entries, no complaint ok: false
.gz truncated mid-payload one entry, no complaint ok: false
.gz whose CRC32 no longer matches one entry, no complaint ok: false

The last two matter: header-level checks cannot see payload damage, so list succeeding says nothing about whether the data survived transfer.

head(archive_path, lines, entry)

Returns the first lines lines of one entry and stops there — the rest of the stream is never decompressed. Line terminators are excluded and a trailing \r is dropped, so CRLF files read like LF ones.

// First line of a bare .csv.gz
var header = Archive::head("logs/2026-08.csv.gz", 1, null).get(0);

// First 5 lines of one member inside a zip
var preview = Archive::head("bundle.zip", 5, "data/readings.csv");
Argument Behavior
lines Maximum returned. Fewer if the entry holds fewer; empty array if 0 or less
entry Entry name as list reports it. null selects the first entry

Raises if the file is not an archive, if no entry matches entry, or if a single line exceeds 1 MB — a file with no line breaks in it would otherwise turn “give me the first line” into “decompress everything”.

create(archive_path, format, sources)

Writes a new archive, overwriting archive_path if it exists. Directories are added recursively. Each source’s path as given becomes its entry name, so passing "data/a.txt" stores it as data/a.txt, not a.txt.

Archive::create("backup.tar.zst", ArchiveFormat::tar_zst, Array<String> {
    "data/",
    "config.json",
});

// Bare gzip: exactly one regular file, no container
Archive::create("report.csv.gz", ArchiveFormat::gzip, Array<String> { "report.csv" });

ArchiveFormat::gzip is the exception to the rules above: it takes exactly one source, which must be a regular file, and raises on multiple sources or a directory.

create_from(archive_path, format, sources, base_dir)

create with entry names taken relative to base_dir rather than from each source’s own path. create(a, f, s) is exactly create_from(a, f, s, null).

// stores config/config.json and values/frag_30.csv
Archive::create_from("backup.zip", ArchiveFormat::zip, Array<String> { dir }, dir);

// without base_dir: stores <dir>/config/config.json
Archive::create(  "backup.zip", ArchiveFormat::zip, Array<String> { dir });

With sources set to [base_dir] this reproduces cd "$base_dir" && zip -r "$archive_path" . exactly, entry name for entry name — directory entries and their trailing slashes included — so archives stay interchangeable with ones produced that way.

  • base_dir itself contributes no entry; an archive entry cannot be nameless.
  • Every source must be base_dir or live under it. Anything else raises rather than falling back to a full path, which would put a differently-shaped tree in the archive than asked for.
  • Matching is literal, ignoring trailing slashes — not realpath, which would resolve symlinks and silently change stored names. Spell both consistently.
  • base_dir must be a directory when it is also a source.

ArchiveEntry

Field Type Description
path String Path as stored in the archive, directory components included
kind ArchiveEntryKind file, directory or symlink
size int Uncompressed size in bytes. 0 for directories and symlinks
modified time? Last-modified time recorded in the archive, if any

ArchiveCheck

Field Type Description
ok bool True if the whole stream decompressed and parsed cleanly
format String? Detected container, e.g. "raw", "ZIP 2.0 (deflation)". Descriptive only
compression String? Detected filter, e.g. "gzip", "xz", "zstd", "none"
size int Uncompressed bytes read. 0 when ok is false
error String? Failure reason. null when ok is true

format and compression are libarchive’s own descriptions, meant for logs and messages — do not switch on them.

ArchiveFormat

zip, gzip, tar, tar_gz, tar_xz, tar_zst, sevenzip. Used only by create; reads auto-detect.

ArchiveEntryKind

file, directory, symlink.

Bare Compressed Files

A file produced by running gzip/xz/zstd directly on a file has no archive container inside it, and carries neither the original filename nor an upfront size. Those read back as a single entry named "data":

Archive::list("report.csv.gz").get(0).path;   // "data"
Archive::extract("report.csv.gz", "out/");    // writes out/data
Archive::head("report.csv.gz", 1, null);      // null entry == "data"

Anything carrying neither a container nor compression — a plain CSV, a renamed PDF, a 0-byte or half-written upload — is not an archive. list and extract raise on it rather than treating it as a one-entry archive, so a caller that could only check an extension beforehand can still tell “that is not an archive” from “that archive is empty”.

Error Handling

Call On bad input
list, extract, head, create, create_from raises — catch with try/catch
verify never raises — returns ok: false with error
// verify is a predicate: no try/catch needed
if (!Archive::verify(path).ok) {
    return;
}

// everything else raises
try {
    Archive::extract(path, "out/");
} catch (e) {
    warn("extract failed: ${e}");
}

Performance

Measured on a 27 MB CSV compressed to 4.9 MB (macOS arm64):

Operation Time
head(path, 1, null) 0.35 ms
verify(path) — bare .gz 19.4 ms
verify(path).tar.gz 50.6 ms
gzip -t + gzip -dc | head -n 1 via a shell 26.0 ms

Two properties are worth designing around:

  • head does not scale with file size. It stops at the line it was asked for, so its cost is format detection, not decompression — one line out of a 27 MB .gz and out of a 27 GB one cost the same. Use it freely for header sniffing and previews.
  • verify reads everything, by definition. It is an integrity check, so it pays for the full stream. A gzip-wrapped container (.tar.gz) costs roughly twice a bare .gz, because the container structure and the gzip trailer are checked in separate passes.

Memory stays bounded: verify decodes bare .gz files up to 64 MB in one buffer and switches to a constant-memory stream above that, so arbitrarily large archives are verifiable.

Examples

Validate an upload before processing it

fn ingest(path: String) {
    var check = Archive::verify(path);
    if (!check.ok) {
        throw "rejected ${path}: ${check.error}";
    }
    if (check.size > 1_000_000_000) {
        throw "rejected ${path}: ${check.size} bytes uncompressed is too large";
    }
    Archive::extract(path, "staging/");
}

Because verify reports rather than raises, the “is it good?” and “why not?” questions are answered by one call with no exception handling.

Read a CSV header without extracting anything

fn columns(path: String): Array<String> {
    var lines = Archive::head(path, 1, null);
    if (lines.size() == 0) {
        throw "${path} is empty";
    }
    return lines.get(0).split(',');
}

Preview every CSV inside a bundle

var bundle = "exports/bundle.zip";
for (_, entry in Archive::list(bundle)) {
    if (entry.kind == ArchiveEntryKind::file && entry.path.endsWith(".csv")) {
        var preview = Archive::head(bundle, 3, entry.path);
        info("${entry.path} (${entry.size} bytes)");
        for (_, line in preview) {
            info("  ${line}");
        }
    }
}

Naming an entry costs header reads only — members in between are skipped, never decompressed.

Inventory an archive

var files = 0;
var dirs = 0;
var total = 0;
for (_, e in Archive::list("backup.tar.zst")) {
    if (e.kind == ArchiveEntryKind::directory) {
        dirs = dirs + 1;
    } else {
        files = files + 1;
        total = total + e.size;
    }
}
info("${files} files in ${dirs} directories, ${total} bytes uncompressed");

Round-trip a directory

Archive::create("snapshot.tar.gz", ArchiveFormat::tar_gz, Array<String> { "data/" });

var check = Archive::verify("snapshot.tar.gz");
if (check.ok) {
    Archive::extract("snapshot.tar.gz", "restored/");
}

Zip a directory without its path prefix

// Entries are stored as config/config.json, values/frag_30.csv, ... --
// not as backups/2026-08/config/config.json.
var dir = "backups/2026-08";
Archive::create_from("backup.zip", ArchiveFormat::zip, Array<String> { dir }, dir);

// So extraction reproduces the directory's own contents at the destination
Archive::extract("backup.zip", "restored/");   // restored/config/config.json

This is the shape cd "$dir" && zip -r backup.zip . produces, entry for entry. Plain create would store every entry under backups/2026-08/, which changes the archive’s layout for anything reading it back.

Replacing shell-outs

Archive::verify and Archive::head cover what gzip subprocesses were typically used for, without spawning anything, on every supported format rather than gzip alone, and on platforms with no shell:

Shell GreyCat
gzip -t file.gz Archive::verify(path).ok
gzip -dc file.gz | head -n 1 Archive::head(path, 1, null).get(0)
tar tzf file.tar.gz Archive::list(path)
tar xzf file.tar.gz -C out/ Archive::extract(path, "out/")
tar czf out.tar.gz data/ Archive::create(out, ArchiveFormat::tar_gz, sources)
cd dir && zip -r out.zip . Archive::create_from(out, ArchiveFormat::zip, Array<String> { dir }, dir)