- name
- exarch-cli
- description
- Extract, create, list, and verify TAR (plus gz/bz2/xz/zst), ZIP, and 7z archives securely using the `exarch` CLI. Use whenever a task involves unpacking or building archives, especially from untrusted sources — exarch blocks path traversal, symlink/hardlink escapes, zip bombs, and oversized archives by default, and reports rejections as structured errors instead of silently succeeding.
- license
- MIT OR Apache-2.0
- compatibility
- Requires the `exarch` binary (cargo install exarch-cli, or build from this workspace with cargo build -p exarch-cli). MSRV 1.98.0 if building from source.
- metadata
- {"author":"bug-ops","version":"0.6.1"}
# exarch CLI
`exarch` is a security-first archive tool. It extracts, creates, lists, and verifies TAR
(plain, `.gz`, `.bz2`, `.xz`, `.zst`), ZIP, and 7z archives. Every extraction runs through a
validation pipeline that is deny-by-default: path traversal, symlink/hardlink escapes outside
the destination directory, zip bombs (excessive compression ratio), oversized files/archives,
and unsafe permission bits (setuid/setgid, world-writable) are all rejected unless explicitly
allowed. If an extraction fails with a security-related error, treat that as the tool doing its
job, not a bug to route around (see "Do not reach for `--allow-*` reflexively" below).
## Install
First check whether a Rust toolchain is present: `command -v cargo`.
**`cargo` available (recommended):**
```bash
cargo install exarch-cli
```
This installs a binary named `exarch` (not `exarch-cli`) and is the fastest path to a working
binary since it doesn't require identifying the target triple. Inside this workspace, use the
debug build instead:
```bash
cargo build -p exarch-cli
./target/debug/exarch --help
```
**`cargo` NOT available:**
*Linux / macOS* — run the install script, no toolchain required, checksum-verified:
```bash
curl -fsSL https://raw.githubusercontent.com/bug-ops/exarch/main/scripts/install.sh | sh
```
Installs to `~/.local/bin` (override with `EXARCH_INSTALL_DIR`). If the script itself can't run
(e.g. a restricted shell), fall back to a manual download: get `exarch-<version>-<target>.tar.gz`
plus its `.sha256` from [GitHub Releases](https://github.com/bug-ops/exarch/releases) —
`<target>` is `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `x86_64-apple-darwin`, or
`aarch64-apple-darwin` — then verify and extract:
```bash
sha256sum -c exarch-<version>-<target>.tar.gz.sha256 # macOS: shasum -a 256 -c
tar -xzf exarch-<version>-<target>.tar.gz
```
*Windows* — no install script; download `exarch-<version>-x86_64-pc-windows-msvc.zip` plus its
`.sha256` from [GitHub Releases](https://github.com/bug-ops/exarch/releases), then verify and
extract in PowerShell:
```powershell
(Get-FileHash exarch-<version>-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash
# compare (case-insensitively) against the hash in the .sha256 file, then:
Expand-Archive exarch-<version>-x86_64-pc-windows-msvc.zip -DestinationPath .
```
`CertUtil -hashfile exarch-<version>-x86_64-pc-windows-msvc.zip SHA256` is an equivalent,
cmd-only alternative to `Get-FileHash`.
## Global flags
Available on every subcommand:
| Flag | Effect |
|---|---|
| `-v, --verbose` | Verbose output. Conflicts with `--quiet`. |
| `-q, --quiet` | Suppress non-error output. Conflicts with `--verbose`. |
| `-j, --json` | Emit a machine-readable JSON envelope instead of human-readable text (see below). Always prefer this when parsing output programmatically. |
## Subcommands
### `extract <ARCHIVE> [OUTPUT_DIR]`
```bash
exarch extract archive.tar.gz ./out --json
```
`OUTPUT_DIR` defaults to the **current directory** if omitted — always pass an explicit output
directory rather than relying on the default, so extraction is predictable and easy to clean up.
Key flags (all optional):
| Flag | Default | Purpose |
|---|---|---|
| `--force` | off | Overwrite existing files at the destination. |
| `--atomic` | off | Extract to a temp dir, rename into place on success, clean up on failure. With `--force` and a pre-existing destination, the swap is a backup-then-replace: the existing destination is renamed aside first, the new content is renamed into its place second, and only once that swap succeeds is the old destination removed — never before, so a failure at any point still leaves either the old or the new content in place under the destination name. On Unix, `--force` also requires read permission on the destination's parent directory (used to pin it by file descriptor for the swap); the destination itself does not need to be readable. With `--force`, a destination that is itself a symlink is now rejected — pass the resolved target path instead. Without `--force`, a symlinked destination isn't rejected as such, but the final rename onto it fails (`ENOTDIR` on Unix, `OutputExists` on Windows), since `rename` doesn't follow a symlink at the target — pass the resolved target path here too. Plain (non-atomic) `extract` is unaffected: it resolves a symlinked destination via `canonicalize()` and writes through it, matching `tar -C`/`unzip -d` (see #533). If a failed `--atomic --force` run leaves temp/backup content behind (e.g. an intermediate parent-path component was redirected mid-extraction), the error message discloses that content's actual location — see #530. |
| `--preserve-permissions` | off | Preserve file permissions from the archive (setuid/setgid are still stripped regardless). |
| `--max-files` | 10000 | Max number of entries to extract. |
| `--max-total-size` | 500 MB | Max cumulative extracted size. Accepts `K`/`M`/`G`/`T` suffixes, e.g. `2G`. |
| `--max-file-size` | 50 MB | Max size of any single extracted file. Same suffix syntax. |
| `--max-compression-ratio` | 100 | Max uncompressed/compressed ratio before an entry is treated as a zip bomb. |
| `--max-path-depth` | 32 | Max path component depth allowed per entry. |
| `--allowed-extensions <EXT>` | all allowed | Restrict extraction to matching extensions. Repeatable or comma-separated (`txt,pdf`); leading dot optional, case-insensitive. |
| `--allow-symlinks` | off | Allow symlinks that resolve within the extraction directory. |
| `--allow-hardlinks` | off | Allow hardlinks that resolve within the extraction directory. |
| `--allow-absolute-paths` | off | Allow archive entries with absolute paths. |
| `--allow-world-writable` | off | Allow files with world-writable (mode `0o002`) permission bits. |
| `--allow-solid-archives` | off | Allow solid 7z archives (see Security model below). |
| `--banned-component <COMPONENT>` | see below | Block entries whose path contains this component. **Replaces** the default list — see warning below. Repeatable. |
> [!WARNING]
> **`--banned-component` REPLACES the default ban list, it does not append to it.** The default
> blocks path components `.git`, `.ssh`, `.gnupg`, `.aws`, `.kube`, `.docker`, `.env`. Passing
> `--banned-component foo` drops ALL of those defaults and blocks only `foo` — silently letting
> `.ssh`, `.aws`, etc. through. If you need one extra banned component, repeat the flag with the
> full desired set, not just the addition: `--banned-component .git --banned-component .ssh
> --banned-component .gnupg --banned-component .aws --banned-component .kube --banned-component
> .docker --banned-component .env --banned-component foo`. Passing an empty value disables all
> banning.
### `create <OUTPUT> <SOURCE>...`
```bash
exarch create out.tar.gz src/ --json
```
| Flag | Default | Purpose |
|---|---|---|
| `-f, --force` | off | Overwrite `OUTPUT` if it already exists. |
| `-l, --compression-level <1-9>` | format default | Compression level. |
| `-x, --exclude <PATTERN>` | none | Glob exclude pattern; repeatable. |
| `--strip-prefix <PREFIX>` | none | Strip a path prefix from entry names. |
| `--include-hidden` | off | Include dotfiles/hidden entries. |
| `--follow-symlinks` | off | Follow symlinks when walking source trees (stores target contents, not the link). |
| `--max-file-size <BYTES>` | none | Skip source files larger than this (`K`/`M`/`G`/`T` suffixes). |
| `--preserve-permissions[=true\|false]` | `true` | Preserve platform permission bits. Pass `--preserve-permissions=false` for a portable, permission-agnostic archive. |
> [!NOTE]
> With `--follow-symlinks` off (default), a symlink passed directly as a source (file or
> directory) is archived as a link in TAR; ZIP has no symlink entry type, so it is skipped
> instead (warning + `files_skipped`). With `--follow-symlinks` on, both TAR and ZIP dereference
> the symlink and store the target's content — for a directory symlink, this means walking and
> archiving the entire target tree.
`exarch` refuses to *create* 7z archives (7z is extract-only) and refuses to create archives
under ZIP-family extensions that carry extra format semantics: `.jar`, `.war`, `.ear`, `.nar`,
`.nbm`, `.apk`, `.aab`, `.ipa`, `.appx`, `.msix`, `.whl`, `.vsix`, `.xpi`, `.epub` — use plain
`.zip` for those cases.
### `list <ARCHIVE>`
```bash
exarch list archive.zip -l -H --json
```
| Flag | Default | Purpose |
|---|---|---|
| `-l, --long` | off | Detailed per-entry info (type, size, mode, timestamps, link targets). |
| `-H, --human-readable` | off | Human-readable sizes in text mode (ignored in `--json` mode, which always emits raw byte counts). |
| `--max-files` | 10000 | Hard limit on entries scanned; listing fails with `QuotaExceeded` past this count, it does not truncate. |
| `--max-total-size` | 500 MB | Hard limit on cumulative listed size; listing fails with `QuotaExceeded` past this size. Raise it explicitly for larger archives. |
| `--max-file-size` | 50 MB | Hard limit on any single entry's size; listing fails with `QuotaExceeded` (`FileSize`) past this size. Raise it explicitly for archives with large individual files. |
| `--allow-solid-archives` | off | Allow listing solid 7z archives (higher memory use). |
Verified live: a 51 MB tar entry fails `exarch list` with
`"quota exceeded: single file size (53477376 > 52428800)"` at the 50 MB default, and succeeds
once `--max-file-size 100M` is passed.
### `verify <ARCHIVE>`
```bash
exarch verify archive.tar.gz --check-integrity --check-security --json
```
| Flag | Default | Purpose |
|---|---|---|
| `--check-integrity` | off | Validate checksums/structure. |
| `--check-security` | off | Run the security validation pipeline (path traversal, symlink/hardlink, permissions) without extracting. |
| `--strict` | off | Treat any warning-level finding as an error and exit with status `2` instead of `0`. |
| `--max-files` / `--max-total-size` / `--max-file-size` / `--allow-solid-archives` | same as `list` (10000 / 500 MB / 50 MB / off) | Same hard-limit semantics as `list` for file count and total size — verification fails with `QuotaExceeded` past those two caps, it does not skip entries. |
Run `verify` before `extract` on any archive from an untrusted source to inspect issues without
writing anything to disk. Archives over 500 MB need `--max-total-size` raised explicitly or
`verify` will fail with `QuotaExceeded` before it can report anything else.
`--max-file-size` is the one exception to "fails with `QuotaExceeded`": an entry over this cap
does **not** abort `verify` early. `verify`'s internal pre-flight listing pass always allows
entries of any size through, then re-checks the real `--max-file-size` per entry and records a
violation as a `HIGH`-severity `QuotaExceeded` issue, same as any other security finding —
`data.status` becomes `"FAIL"` with the issue itemized, `status` (the envelope) stays
`"success"`, same as the `verify` "one exception" rule described under `--json` output envelope
below. Verified live: a 51 MB tar entry against the 50 MB default produces `data.status: "FAIL"`
with one `HIGH` `"Quota Exceeded"` issue and exit code `1`; the same command with
`--max-file-size 100M` produces `data.status: "PASS"` and exit code `0`.
When the archive has `status: "FAIL"`, `verify --json` prints exactly one top-level JSON
document to stdout — a `status: "success"` envelope carrying the full report (`data.status:
"FAIL"`, findings in `data.issues`). Verified live on an archive containing a bare symlink
entry: a single valid JSON document, parseable with a plain `json.loads(stdout)`. The process
still exits `1` for a FAIL status.
### `completion <SHELL>`
```bash
exarch completion zsh > ~/.zsh/completions/_exarch
```
Valid `<SHELL>` values (case-sensitive, exact set): `bash`, `elvish`, `fish`, `powershell`, `zsh`.
## Do not reach for `--allow-*` reflexively
`--allow-symlinks`, `--allow-hardlinks`, `--allow-absolute-paths`, `--allow-world-writable`,
`--allow-solid-archives`, and `--force` all weaken a default-deny security check. When an
extraction fails with `SymlinkEscape`, `HardlinkEscape`, `PathTraversal`, `ZipBomb`,
`InvalidPermissions`, or `SecurityViolation`, that is the validator correctly rejecting
something dangerous — **do not add the matching `--allow-*` flag just to make the command
succeed.** Only add it when the archive's origin and contents are already known to be trusted,
and prefer re-running just `verify --check-security` first to see the full list of findings
before deciding. `--force` (extract/create) and `--atomic` (extract) are safe to use more
liberally since they affect overwrite behavior, not path/link/permission validation.
## `--json` output envelope
Every subcommand's `--json` output uses the same envelope:
```jsonc
// success
{
"operation": "extract" | "create" | "list" | "verify",
"status": "success",
"data": { /* per-command payload, see below */ }
}
// error
{
"operation": "extract" | "create" | "list" | "verify",
"status": "error",
View on GitHub