Building a static analysis platform in Rust
How Sonda is put together — tree-sitter parsing in sandboxed worker processes, a layered Rust workspace, deterministic reports — and the trade-offs behind each choice.
A static analyzer reads code written by other people, on machines that hold other secrets. That one fact drives most of Sonda's architecture. This is a tour of how it's built and why, drawn from its architecture notes and decision records.
The pipeline
A scan is one pass through a fixed pipeline:
collect files walk the root (.gitignore + scan.exclude, no symlinks), sort by path
load coverage read the LCOV files named on the command line
new code merge base + git blob reads → changed lines
analyze per file, in parallel, in worker processes:
parse → match rules → complexity, length, parameters
→ lines, comments, tokens → suppressions
duplication rolling hash over every file's token stream
measures project metrics, overall and on new code
gate pass or fail, per condition
report text, JSON or SARIFEverything up to "analyze" is about deciding what to read, and reading it safely. Everything after is arithmetic over plain data.
Parsing with tree-sitter
Sonda parses with tree-sitter, which has grammars for every language it supports and is fast, incremental and error-tolerant: a file that doesn't compile still parses, with the broken part marked.
Most rules are tree-sitter queries: patterns over the syntax tree. Per language, the enabled rules' queries are combined into one, compiled the first time that language appears, and matched in a single pass over each file. Compiling per language on first use made scans of a 29,000-line JavaScript and C project 40% faster, according to the changelog. Rules that need more than a pattern, such as cognitive complexity, are structural checks over the same tree.
The trade-off is depth. Queries see syntax, not values. A semantic layer adds scopes and declarations for JavaScript/TypeScript and C#, but there's no data-flow or taint analysis yet, so bugs that need a value traced across functions are out of reach for now.
The repository is hostile input
tree-sitter's runtime and grammars are C, called through Rust's FFI. Rust's guarantees stop at that boundary: a grammar bug triggered by a crafted file could crash the process, or worse.
So parsing doesn't happen in the main process. Files are analyzed in worker processes (ADR-0003): a crash costs one file, reported as a warning, and the scan goes on. The changelog measured the cost at 6% on 29,000 lines and 1% on a million.
Then the workers sandbox themselves
(ADR-0004): a seccomp allow-list on
Linux, Seatbelt on macOS, no core dumps and no new file descriptors. A worker that can only read the bytes it was given
and write results back has little to offer an attacker. --sandbox require refuses to scan where that isn't
available; on Windows it isn't yet, which
SECURITY.md lists as a known limitation.
The same suspicion runs through the rest:
- Files are read with a size cap, and parsing and rule matching run against a per-file deadline.
- Syntax trees are walked with an explicit stack, never recursively, so deep nesting can't overflow the stack.
- git only reads the object database. A repository's own
.git/configcan makegit diffrun programs (an fsmonitor, a clean filter), so Sonda diffs files against their base versions in-process instead (ADR-0002). - Symlinks aren't followed.
- Anything printed to a terminal has its control characters escaped first.
- Analysis and config parsing are fuzzed in every grammar.
A layered workspace
The code is a Cargo workspace of three crates:
| Crate | What it is |
|---|---|
sonda-core |
The library: analysis, rules, metrics, the gate, report rendering |
sonda |
The CLI binary, and the HTTP client for uploads |
sonda-server |
The server: uploads, storage in PostgreSQL, the API, the web app |
Inside sonda-core, dependencies point one way
(ADR-0001): the interface (arguments,
rendering) depends on the application entry points, which depend on infrastructure (filesystem, git, workers), which
depends on the domain (analysis, rules, measures, the gate). Tests enforce the direction. The CLI and the server share
one rule catalog and one report model, because the server depends on sonda-core too.
Determinism
The same code and configuration produce a byte-identical report, whatever the thread count or filesystem order. That makes reports comparable over time and diffable in review. It takes some discipline:
- Discovered files are sorted before analysis, and the parallel collection keeps that order.
- Issues and warnings are sorted on all their fields.
- Token hashes use FNV-1a, which is stable across Rust releases, unlike the standard library's hasher.
- Hash maps are used for lookups, never iterated into output.
- A test runs the pipeline in two input orders, with one and four threads, and compares the results.
The one exception is a file that hits its time budget, which can depend on machine speed. It's always reported as a warning.
Errors and exit codes
A failure that stops the run is a typed error: configuration, an invalid argument, I/O on a file the user named, git,
or internal. The binary turns it into exit code 2. Exit code 1 means one thing only, a failed quality gate, so CI
can tell "the code isn't good enough" from "the tool couldn't run". A problem with one analyzed file never stops the
run; it becomes a warning in the report.
Trade-offs, briefly
- Syntax first, semantics later. Queries are fast and predictable, and they cover most of SonarQube's rule keys for the supported languages. Data flow comes later, by design.
- Processes over threads for parsing. A few percent of speed buys a crash boundary and a sandbox.
- One binary. The CLI needs no runtime and no server, which makes it easy to put in any CI job.
The decision records cover each choice in more detail, including the options that were rejected.