Skip to content

Architecture

exav is a small set of focused crates around a constant-memory scanning core. This is the high-level picture; deeper implementation detail lives in the repository’s docs/.

exav scans through one of two paths, and the difference decides what analysis is possible:

  • Stream (Read: stdin, pipes) — scan_stream runs the constant-memory pattern + hash core. Unlimited size, but structural unpacking needs seeking, so a pure pipe does pattern+hash only.
  • Seekable (Read + Seek: local files, or an HTTP range reader over HTTP range GETs) — scan_seekable additionally drives archive extraction through the reader, fetching only the directory and members it actually scans.

See Streaming & memory for the core.

The code is split across a handful of crates — exav and the WASI build are the front-ends, exav-core is the engine, exav-unpack does extraction (and backs the standalone exav-grep). See How the crates compose below, and Subprojects for what each one owns.

Different ClamAV signature types compile to different runtime structures — there is deliberately not one giant matcher:

Source Runtime structure Matching
.ndb bodies + .ldb literal subsignatures a shared Aho-Corasick automaton keyed on a literal anchor per body an automaton hit fans out to every body sharing that anchor; each candidate is then verified (wildcards / gaps / nibbles / alternation / offset / nocase)
.ldb PCRE subsignatures regex objects compiled lazily, gated by the trigger expression linear-time, DoS-safe regex
.hsb / .hdb a size-keyed hash table whole-file digest lookup
.mdb / .msb a section-hash table per-PE-section digest lookup
.cdb container-metadata matchers matched on archive members (name/size/encryption/position)
.imp a size-constrained import-hash map PE imphash lookup
.cbc a sandboxed bytecode interpreter (no JIT) trigger-gated programs run on extracted buffers
.yar / .yara a native YARA engine near-full YARA, no runtime codegen

So the engine is really: one Aho-Corasick automaton (fed by ndb + ldb literal subsigs) + several cheap hash tables + lazy regex + interpreters. Almost all of the memory cost is the automaton — see the database internals.

exav is a small Cargo workspace, not one binary. Four front-ends drive one engine: the CLI (which is also the daemon), the WASI build, the archive grep, and the WebAssembly bindings published to npm. exav-core is the engine; exav-unpack does extraction; exav-update sits to the side, feeding fresh signatures out of band.

Every arrow points down. Nothing below calls anything above it, which is what lets the extraction crates be taken on their own.

exav crate dependency graph Four front ends sit on top: exav, exav-core built for wasm32-wasip1, exav-grep and exav-unpack-wasm. exav and the wasi binary depend on exav-core; exav-core, exav-grep and exav-unpack-wasm all depend on exav-unpack, which depends on exav-pe-emu, which depends on exav-x86. exav-update hangs off exav alone and feeds signature files out of band. FRONT ENDS exav exav-core exav-grep exav-unpack-wasm exav-core exav-unpack exav-pe-emu exav-x86 exav-update clamscan + clamd the only unsafe: libc --wasi-bin wasm32-wasip1 grep inside archives wasm-bindgen → npm its own profile typing · patterns · hashes YARA · .cbc VM every container format · Budget / Limits #![forbid(unsafe_code)] x86-32 sandbox decoder signatures, out of band runs a packer's own stub to recover the image it rebuilds decode only, zero dependencies

A scan flows through it like this:

How a scan flows A file enters identify(), which types the first 4 KiB. A container has its members walked one at a time under a single shared Budget, and each member re-enters identify() bounded at max_recursion of 16. A flat file goes to pattern, hash and heuristic matching. Both paths end in one of five verdicts: Clean, Infected, LimitsExceeded, Unscannable or PasswordProtected. file identify() container flat Clean · Infected · LimitsExceeded Unscannable · PasswordProtected types the first 4 KiB walk members one at a time, sharing one Budget pattern + hash + heuristics every scan ends in exactly one verdict each member re-enters identify() bounded at max_recursion (16)

The five verdicts are the whole output vocabulary. Anything that stops a scan early produces one of the last three rather than Clean — see Verdicts & exit codes.

The split is not cosmetic. Extraction touches hostile bytes first and hardest, so it lives in its own #![forbid(unsafe_code)] crate with its own budget and panic containment; a malformed archive cannot reach the engine, let alone the host. It also means you can take one piece — a build system that needs to look inside archives does not need a virus scanner, and a reverse engineer who wants to unpack a packed executable needs neither.

exav-pe-emu sits below the extractor for the same reason. Running a packer’s stub is the one place where the scanner executes attacker-authored control flow rather than parsing attacker-authored data, so it is its own crate with its own budgets, its own #![forbid(unsafe_code)], and no way to reach a syscall.

Each crate has its own page under Subprojects, with install instructions, API, and examples.

The exav-unpack crate recursively extracts archives and structured documents (see Supported formats). Every path runs under decompression-bomb defenses: output-byte, ratio, file-count, recursion-depth, and cumulative scan-byte budgets. A bomb is LIMITS-EXCEEDED, never OK; a member with an unsupported codec or encryption is UNSCANNABLE / PASSWORD-PROTECTED, never silently dropped — the never-silent invariant reaching all the way down.

File type is decided by magic bytes, never by extension — an executable renamed .jpg is still typed and scanned as an executable.