# "Archive"
_Path: en/lua/data/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
```lua
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.
```lua
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).
```lua
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):
```lua
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):
```lua
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()`:
```lua
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:
```lua
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:
```lua
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:
```lua
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.
```lua
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)`.
```lua
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`:
```lua
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.
```lua
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`:
```lua
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:
```lua
w:add_file("data/big.bin", fs.get("app:data"), "big.bin")
```
### add_dir
Add a directory entry:
```lua
w:add_dir("empty/")
```
### close
Finalize the archive (writes the central directory for zip). Idempotent; also auto-closed at task scope.
```lua
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](lua/core/errors.md) for working with errors.
## See Also
- [Filesystem](lua/storage/filesystem.md) - Source and destination filesystems
- [Stream](lua/core/stream.md) - Stream objects handed to and from archives
- [Compression](lua/data/compress.md) - In-memory gzip/deflate/zstd
## Navigation
Previous: "Compression" (lua/data/compress)
Next: "Payload Encoding" (lua/data/payload)