Article

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.

Lucas Oliveira4 min read

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:

output
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 SARIF

Everything 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/config can make git diff run 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.