In this page
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 -dprocess for this filter. Where that binary is missing, reads fail withunable 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_diritself contributes no entry; an archive entry cannot be nameless.- Every source must be
base_diror 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_dirmust 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:
headdoes 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.gzand out of a 27 GB one cost the same. Use it freely for header sniffing and previews.verifyreads 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) |