# zip

> Pack files into ZIP and tar archives, unpack them safely, and gzip single files or strings.

This is the Markdown version of https://osl-lang.org/packages/zip. Every page of the OSL docs has one: add .md to its path. https://osl-lang.org/llms.txt lists them all.

```osl
import "std:zip"
```

`zip` makes and reads archives. Use it to bundle a folder for sending, unpack a download, peek inside an archive without extracting it, or shrink a log file or a string.

```osl
import "std:zip"
```

## Compress a folder

`zip.compress(path, output)` packs a file or a whole folder into a new ZIP archive. `zip.entries(path)` lists what is inside, with sizes in bytes.

```osl
import "std:zip"
import "std:fs"

fs.remove("docs", {force: true}).unwrap()
fs.mkdir("docs/img", {recursive: true}).unwrap()
fs.writeFile("docs/readme.txt", "hello hello hello hello").unwrap()
fs.writeFile("docs/img/note.txt", "tiny").unwrap()

zip.compress("docs", "docs.zip").unwrap()
for e in zip.entries("docs.zip").unwrap() (
  log e.name e.isDir e.size
)
```

Output:

```text
docs/ true 0
docs/img/ true 0
docs/img/note.txt false 4
docs/readme.txt false 23
```

Try it: add a third file to the folder and run it again. The archive keeps the folder's name as its top level.

## Extract

`zip.decompress(path, folder)` unpacks everything into a folder, creating it if needed. `zip.extractFile(path, name, output)` takes out one entry.

```osl
import "std:zip"
import "std:fs"

fs.remove("docs", {force: true}).unwrap()
fs.mkdir("docs", {recursive: true}).unwrap()
fs.writeFile("docs/readme.txt", "read me").unwrap()
zip.compress("docs", "docs.zip").unwrap()

zip.decompress("docs.zip", "unpacked").unwrap()
log fs.readFile("unpacked/docs/readme.txt").unwrap()

zip.extractFile("docs.zip", "docs/readme.txt", "just-one.txt").unwrap()
log fs.readFile("just-one.txt").unwrap()
```

Output:

```text
read me
read me
```

## When it goes wrong

Missing archives are `NotFoundError`. A file that is not an archive is a `ParseError`. Both come back as [results](/learn/errors.md), and a failed write leaves nothing half done.

```osl
import "std:zip"
import "std:fs"

fs.writeFile("plain.txt", "I am not a zip").unwrap()
log zip.decompress("missing.zip", "out").unwrapErr().kind
log zip.entries("plain.txt").unwrapErr().kind
```

Output:

```text
NotFoundError
ParseError
```

## Edit an archive

`create` makes an empty archive, `addFile` adds a file or folder (replacing entries with the same name), and `removeFile` deletes an entry. Removing a name the archive doesn't have does nothing.

```osl
import "std:zip"
import "std:fs"

fs.writeFile("a.txt", "A").unwrap()
fs.writeFile("b.txt", "B").unwrap()

zip.create("bundle.zip").unwrap()
zip.addFile("bundle.zip", "a.txt").unwrap()
zip.addFile("bundle.zip", "b.txt").unwrap()
zip.removeFile("bundle.zip", "a.txt").unwrap()
for e in zip.entries("bundle.zip").unwrap() ( log e.name )
```

Output:

```text
b.txt
```

## Safe extraction

Archives from strangers can be traps: entries that write outside the target folder, or a tiny file that expands to gigabytes. `decompress` checks every entry before it writes anything and refuses paths that escape the folder. Set limits too:

```osl
import "std:zip"
import "std:fs"

fs.mkdir("many", {recursive: true}).unwrap()
fs.writeFile("many/1.txt", "1").unwrap()
fs.writeFile("many/2.txt", "2").unwrap()
zip.compress("many", "many.zip").unwrap()

log zip.decompress("many.zip", "out", {maxFiles: 1}).unwrapErr().kind
log zip.decompress("many.zip", "out", {maxBytes: 1}).unwrapErr().kind
log zip.decompress("many.zip", "out", {maxFiles: 10, maxBytes: 1000}).isOk()
```

Output:

```text
ValueError
ValueError
true
```

> **Warning**
>
> Always set `maxBytes` and `maxFiles` when you unpack something you didn't make yourself.

## gzip a file

ZIP holds many files. gzip compresses one. `zip.gzip(path, output)` and `zip.gunzip(path, output)` are a pair, and they take size limits as well.

```osl
import "std:zip"
import "std:fs"

string text = "log line\n" * 100
fs.writeFile("app.log", text).unwrap()
zip.gzip("app.log", "app.log.gz").unwrap()
zip.gunzip("app.log.gz", "restored.log").unwrap()

log fs.size("app.log").unwrap()
log fs.size("app.log.gz").unwrap() < fs.size("app.log").unwrap()
log fs.readFile("restored.log").unwrap() == text
```

Output:

```text
900
true
true
```

## Compress a string

`compressString` gzips text and returns it as base64, a string you can store in JSON or send in a header. `decompressString` reverses it.

```osl
import "std:zip"

string packed = zip.compressString("abc" * 50)
log packed.len < 150
log zip.decompressString(packed).unwrap().len
log zip.decompressString("not base64!").unwrapErr().kind
```

Output:

```text
true
150
ParseError
```

## tar

`zip.tar(path, output)` and `zip.untar(path, output)` work like `compress` and `decompress` for tar archives, which store files and folders only. Combine with `gzip` for a `.tar.gz`.

```osl
import "std:zip"
import "std:fs"

fs.mkdir("proj", {recursive: true}).unwrap()
fs.writeFile("proj/main.osl", "log 1").unwrap()
zip.tar("proj", "proj.tar").unwrap()
zip.untar("proj.tar", "copy").unwrap()
log fs.readFile("copy/proj/main.osl").unwrap()
```

Output:

```text
log 1
```

**Quiz.** You unpack a ZIP someone uploaded to your server. What should you do?

- [ ] Call `decompress` with no options.
- [x] Pass `maxBytes` and `maxFiles` so a malicious archive can't fill the disk.
- [ ] Use `extractFile` on every entry yourself.

Answer: `decompress` already refuses entries that escape the folder, and the limits stop archives that expand hugely.

### Exercise: Backup and check

Compress the `notes` folder to `notes.zip`, then print how many files (not folders) are in the archive.

Starting code:

```osl
import "std:zip"
import "std:fs"

fs.remove("notes", {force: true}).unwrap()
fs.mkdir("notes/old", {recursive: true}).unwrap()
fs.writeFile("notes/a.txt", "a").unwrap()
fs.writeFile("notes/old/b.txt", "b").unwrap()
log fs.exists("notes")
```

It should print:

```text
2
```

Hint 1: `zip.entries("notes.zip").unwrap()` gives records with an `isDir` flag.

A solution:

```osl
import "std:zip"
import "std:fs"

fs.remove("notes", {force: true}).unwrap()
fs.mkdir("notes/old", {recursive: true}).unwrap()
fs.writeFile("notes/a.txt", "a").unwrap()
fs.writeFile("notes/old/b.txt", "b").unwrap()

zip.compress("notes", "notes.zip").unwrap()
int files = 0
for e in zip.entries("notes.zip").unwrap() (
  if !e.isDir ( files++ )
)
log files
```

## API reference

## API reference

Every member of `std:zip`, generated from the source of OSL.

### Functions

#### `zip.addFile(string path, string file) result<unit, error>`

addFile adds the file or directory at file to the existing ZIP archive at
path, replacing entries with the same names. Fails with NotFoundError when
the archive or file doesn't exist and ParseError when path isn't a ZIP
archive; on failure the archive is unchanged.

#### `zip.compress(string path, string output) result<unit, error>`

compress writes the file or directory at path to a new ZIP archive at
output, named after path's last element, replacing any file there. Fails
with NotFoundError when path doesn't exist and PermissionError or IOError
when something can't be read or written; a failed write leaves no archive
behind.

#### `zip.compressString(string text) string`

compressString gzips text and returns the result as standard base64, for
storing or sending compressed text as a string. Undo it with
zip.decompressString.

#### `zip.copyFile(string src, string dst) boolean`

**Deprecated.** Deprecated: use fs.copy(from, to) instead.

copyFile copies the file src to dst, returning false on failure.

#### `zip.create(string path) result<unit, error>`

create writes an empty ZIP archive to path, replacing any file there, so
zip.addFile can fill it. Fails with NotFoundError when the directory
doesn't exist and PermissionError or IOError when it can't be written.

#### `zip.decompress(string path, string output, object? options) result<unit, error>`

decompress extracts the ZIP archive at path into the directory output,
creating it if needed. Every entry is checked before anything is written.
Options: maxBytes (most bytes to write in total) and maxFiles (most
entries), both unlimited by default. Fails with NotFoundError when path
doesn't exist, ParseError for corrupt data or an entry that would land
outside output, UnsupportedError for symbolic link entries or an unknown
compression method, ValueError when the archive passes a limit and
PermissionError when a symbolic link already in output would redirect an
entry. A file that fails part way is removed, but files written before it
stay. Throws ValueError for a limit below 1.

#### `zip.decompressLimited(any zipPath, any outputPath, any ...limits) boolean`

**Deprecated.** Deprecated: use zip.decompress(path, output, {maxBytes: n, maxFiles: n}) instead, which reports why it failed.

decompressLimited(zipPath, outputPath, maxBytes, maxFiles) extracts like
decompress, returning false when the archive has more than maxFiles
entries or expands past maxBytes.

#### `zip.decompressString(string text, object? options) result<string, error>`

decompressString reverses zip.compressString: it decodes base64 text and
gunzips it. Option: maxBytes, the most bytes the result may have
(unlimited by default). Fails with ParseError when text isn't base64 or
the bytes aren't gzip data, and ValueError when the result passes
maxBytes. Throws ValueError for a maxBytes below 1.

#### `zip.entries(string path) result<{comment: string, compressedSize: int64, isDir: boolean, modified: int64, name: string, size: int64}[], error>`

entries lists the members of the ZIP archive at path as records with the
keys name, isDir, size and compressedSize (bytes), modified (unix
milliseconds) and comment. Fails with NotFoundError when path doesn't
exist and ParseError when it isn't a ZIP archive.

#### `zip.extractFile(string path, string name, string output, object? options) result<unit, error>`

extractFile writes the archive entry named name to the file output,
replacing any file there. Option: maxBytes, the most bytes to write.
Fails with NotFoundError when the archive or entry doesn't exist,
ValueError when the entry is a directory or larger than maxBytes and
ParseError for corrupt data; a failed write leaves no file behind. Throws
ValueError for a maxBytes below 1.

#### `zip.fileInfo(any zipPath, any filePath) any`

**Deprecated.** Deprecated: use zip.entries(path) instead and pick the entry by name.

fileInfo describes one entry, or returns null when the archive or entry
can't be found.

#### `zip.gunzip(string path, string output, object? options) result<unit, error>`

gunzip decompresses the gzip file at path into the file output, replacing
any file there. Option: maxBytes, the most bytes to write (unlimited by
default). Fails with NotFoundError when path doesn't exist, ParseError
when it isn't valid gzip data, ValueError when it expands past maxBytes
and PermissionError or IOError for other file errors; a failed write
leaves no file behind. Throws ValueError for a maxBytes below 1.

#### `zip.gunzipLimited(any sourcePath, any outputPath, any maxOutputBytes) boolean`

**Deprecated.** Deprecated: use zip.gunzip(path, output, {maxBytes: n}) instead, which reports why it failed.

gunzipLimited gunzips like gunzip, returning false when the output would
pass maxOutputBytes.

#### `zip.gzip(string path, string output, object? options) result<unit, error>`

gzip compresses the file at path into a gzip file at output, replacing any
file there. Options: maxInputBytes (most bytes to read from path) and
maxBytes (most bytes to write), both unlimited by default. Fails with
NotFoundError when path doesn't exist, ValueError when a limit is passed
and PermissionError or IOError for other file errors; a failed write
leaves no file behind. Throws ValueError for a limit below 1.

#### `zip.gzipLimited(any sourcePath, any outputPath, any ...limits) boolean`

**Deprecated.** Deprecated: use zip.gzip(path, output, {maxInputBytes: n, maxBytes: n}) instead, which reports why it failed.

gzipLimited(sourcePath, outputPath, maxInputBytes, maxOutputBytes) gzips
like gzip, returning false when the input or output passes its limit.

#### `zip.list(any zipPath) any`

**Deprecated.** Deprecated: use zip.entries(path) instead, which reports why it failed.

list returns the archive's entries as objects, or [] when it can't be
read.

#### `zip.removeFile(string path, string name) result<unit, error>`

removeFile removes the entry named name from the ZIP archive at path.
Removing an entry the archive doesn't have does nothing, like fs.remove.
Fails with NotFoundError when the archive doesn't exist and ParseError
when path isn't a ZIP archive; on failure the archive is unchanged.

#### `zip.statistics(any zipPath) any`

**Deprecated.** Deprecated: use zip.entries(path) instead and total the sizes.

statistics totals the archive's entries, or returns null when it can't be
read.

#### `zip.tar(string path, string output) result<unit, error>`

tar writes the file or directory at path to a new tar archive at output,
named after path's last element, replacing any file there. Only regular
files and directories are stored. Fails like zip.compress; a failed write
leaves no archive behind.

#### `zip.untar(string path, string output, object? options) result<unit, error>`

untar extracts the tar archive at path into the directory output, like
zip.decompress, and takes the same maxBytes and maxFiles options. Only
regular files and directories are supported; other entry types such as
links fail with UnsupportedError before anything is written.
