Archive
Read and write zip/tar archives with bounded memory. Archives are never loaded into RAM nor extracted to disk — peak memory is independent of archive and entry size, so multi-GB archives run on a low-RAM server.
Loading
local archive = require("archive")
Formats
Built-in formats are detected by magic bytes, or forced with opts.format:
| Format | Random read | Sequential scan | Write |
|---|---|---|---|
zip |
yes | yes (local headers) | yes |
tar |
yes | yes | yes |
tar.gz |
no | yes | yes |
tar.zst |
no | yes | yes |
archive.formats() returns the list of registered format names.
local names = archive.formats() -- {"zip", "tar", "tar.gz", "tar.zst", ...}
Options
All entrypoints accept an optional opts table:
| Key | Default | Meaning |
|---|---|---|
format |
auto | "zip", "tar", "tar.gz", "tar.zst"; auto = sniff magic, else extension |
max_entries |
100000 | Reject archives with more entries (decompression-bomb defense) |
max_total_bytes |
2 GiB | Cap on cumulative uncompressed output during read/extract |
max_file_bytes |
1 GiB | Cap on a single entry's uncompressed size |
max_inline_bytes |
16 MiB | Hard cap for the RAM-materializing read() call; above it, use stream()/extract() |
buffer_bytes |
64 KiB | Streaming copy buffer for read/extract/add |
max_total_bytes/max_file_bytes are work caps, not RAM caps — streaming an entry never holds more than buffer_bytes plus the codec's decompression window. The only RAM-sizing knob is max_inline_bytes.
Reading — Random Access
archive.open(source, ...) opens a seekable source for full random access (zip central directory is read up front; entries decompress on demand). The source may be an fs.FS handle plus a path, an open fs.File, or raw bytes (bytes hold the whole archive in RAM — small archives only).
local fs = require("fs")
local archive = require("archive")
-- Open by fs handle + path (the module opens the file and owns its lifecycle)
local r, err = archive.open(fs.get("app:uploads"), "incoming.zip")
-- Or from an already-open seekable fs.File
-- local r = archive.open(fs:get("app:uploads"):open("x.zip"))
-- Or from raw bytes (small archives only)
-- local r = archive.open(zip_bytes, { format = "zip" })
Returns: Reader, error
Permission: archive.read
entries
Iterate the directory (metadata only — no decompression):
for e in r:entries() do
-- e: name, size, compressed_size, is_dir, mode, modified, method, crc32, type
print(e.name, e.size, e.is_dir)
end
stat
Get entry metadata by name (no decompression):
local info, err = r:stat("docs/readme.md")
read
Materialize a single entry as a Lua string. Errors (kind = Invalid) above max_inline_bytes — for anything large, use stream() or extract():
local data, err = r:read("docs/readme.md") -- small entries only
stream
Return the entry as a stream.Stream that decompresses on demand. Composes everywhere a stream does — :scanner(), fs:writefile(), or handed to another module:
local es, err = r:stream("big.csv")
while true do
local chunk = es:read(65536)
if not chunk then break end
process(chunk)
end
es:close()
extract
Stream one entry into a destination filesystem:
local ok, err = r:extract("docs/readme.md", fs.get("app:out"))
-- optional destination path:
-- r:extract("docs/readme.md", fs.get("app:out"), "readme.md")
extract_all
Stream every entry into a destination filesystem:
local count, err = r:extract_all(fs.get("app:out"), {
prefix = "job123/", -- prepend to each destination path
strip = 1, -- drop N leading path components
filter = function(e) return not e.is_dir end,
})
Entry names are sanitized on extract — .. segments, absolute paths, and Windows drive/UNC prefixes are rejected (zip-slip defense).
close
Close the reader. Idempotent; also auto-closed at task scope.
r:close()
Reading — Sequential Scan
archive.scan(source, opts?) opens a forward-only stream (an HTTP upload body, a multipart file stream). Entries are visited in archive order; each entry's reader is valid only until you advance. No random read(name).
local up = form.files.upload[1]:stream() -- stream.Stream
local s, err = archive.scan(up, { format = "zip" })
for e, entry in s:walk() do -- entry is a stream.Stream
if not e.is_dir then
fs.get("app:uploads"):writefile("job123/" .. e.name, entry)
end
end
s:close()
Returns: Walker, error
Permission: archive.read
tar, tar.gz, and tar.zst stream natively. zip is parsed via per-entry local headers; entries written with a streaming data descriptor (size/CRC trailing the data) are read by decompressing to the entry boundary. For robust zip handling of large uploads, land the upload as a file first (a bounded sequential copy) then use archive.open:
local dst = fs.get("app:tmp")
dst:writefile("u.zip", req:stream()) -- streaming copy upload → fs file
local r = archive.open(dst, "u.zip") -- robust random access
-- ... entries / extract_all ...
r:close()
dst:remove("u.zip")
Writing
archive.create(dest, ...) builds an archive by streaming entries into a destination — a file in an fs (with a path) or a writable stream.Stream (e.g. an HTTP response), so a download .zip is generated straight to the wire with bounded memory.
local w, err = archive.create(fs.get("app:tmp"), "out.zip", { format = "zip" })
-- or stream to a response:
-- local w = archive.create(res:stream(), { format = "zip" })
Returns: Writer, error
Permission: archive.write
add
Add an entry from a string, bytes, reader, or stream.Stream:
w:add("notes.txt", "hello")
w:add("from_upload", some_stream, { method = "deflate", mode = 0644 })
add_file
Stream an entry from a file in a filesystem:
w:add_file("data/big.bin", fs.get("app:data"), "big.bin")
add_dir
Add a directory entry:
w:add_dir("empty/")
close
Finalize the archive (writes the central directory for zip). Idempotent; also auto-closed at task scope.
w:close()
add* options: { method = "store"|"deflate", mode, modified }. The zip writer streams to non-seekable writers using data descriptors, so writing to a response stream works.
Errors
| Condition | Kind |
|---|---|
| Unknown / mismatched format | errors.INVALID |
| Corrupt or truncated archive | errors.INVALID |
| Limit exceeded (entries / total / file / inline) | errors.INVALID |
Random access on a stream-only format (use scan) |
errors.UNAVAILABLE |
| Entry name not found | errors.NOT_FOUND |
| Source not readable / destination not writable | errors.PERMISSION_DENIED |
| Read a stale streamed entry after the walk advanced | errors.INTERNAL |
See Error Handling for working with errors.
See Also
- Filesystem - Source and destination filesystems
- Stream - Stream objects handed to and from archives
- Compression - In-memory gzip/deflate/zstd