Skip to content

feat(rust): add a Rust implementation sharing the Rego rules - #97

Open
starkross wants to merge 1 commit into
mainfrom
feat/rust-port
Open

feat(rust): add a Rust implementation sharing the Rego rules#97
starkross wants to merge 1 commit into
mainfrom
feat/rust-port

Conversation

@starkross

Copy link
Copy Markdown
Owner

A second implementation of the linter in Rust, sharing the Rego rules with the Go one. Nothing in the Go tree changes.

Why

The Go binary embeds the OPA interpreter. As a wasip1 module that is 46,593,356 bytes, and stripping the entire CLI and cobra saves 1.6 MB of it β€” the interpreter is ~96%. The Go runtime floor alone is 1.6 MB, and TinyGo can't compile OPA (reflection), so there is no route down from inside Go.

Swapping OPA for regorus and trimming unused features gives the same linter in 1,184,533 bytes (330 KB brotli):

build raw brotli
Go + OPA (make wasm) 46,593,356 6.04 MB
Rust + regorus (make rust-wasm) 1,184,533 330 KB

39Γ— smaller, and faster: the wasm module compiles in 2 ms and runs a lint in 31 ms, against 45 ms and ~300 ms for the Go module. Verified running under Node WASI β€” -o json output is byte-identical to the Go native binary on three configs.

Structure

  • augur-core β€” parsing, env substitution, deep merge, evaluation. No I/O; callers pass strings, so it works unchanged in a CLI, a server, or a browser.
  • augur-cli β€” the augur binary. Flags and all three output formats match the Go CLI.

rules/policy/ stays the single source of truth. augur-core/build.rs walks it at compile time, so a new .rego file is picked up by both implementations without touching Rust β€” the two can't silently drift as rules are added.

Two behaviours reproduced rather than fixed

Both were found by differential testing, and both change lint results:

YAML 1.1 scalars. sigs.k8s.io/yaml wraps go-yaml v2, which resolves yes/no/on/off as booleans. Rust YAML crates follow the YAML 1.2 core schema and leave them strings. Uncoerced, a config with insecure_skip_verify: yes stops matching == true:

GO:    warn|OTEL-032   warn|OTEL-048
RUST:  warn|OTEL-065          ← OTEL-032 silently missing

OTEL-032 is "TLS verification is bypassed" β€” the worst possible thing for a linter to quietly drop. The coercion applies to mapping keys too, because go-yaml does: a bare y: key becomes true, which sigs.k8s.io/yaml then renders as the string "true". There's a test pinning that, surprising as it looks.

Builtin error handling. OPA leaves an expression undefined when a builtin hits a runtime type error and carries on; regorus aborts by default. An empty config section (grpc: with no body, as in examples/bad.yaml) parses as null, reaches object.get, and under the strict default takes the whole evaluation down β€” 14 findings become 0. set_strict_builtin_errors(false) restores OPA's behaviour. Confirmed with opa eval --strict-builtin-errors, which reproduces the regorus error exactly.

"findings": null for a clean config is preserved too β€” Go marshals a nil slice that way, and emitting [] would change the shape consumers parse.

regorus is pinned to a commit

0.11.0 was published 2026-07-21; the fix for function calls through import aliases (microsoft/regorus#747, via #769) merged 2026-07-23. The policies call lib.pipeline_receivers(t) after import data.lib at 48 sites, which the released crate rejects with could not find function. The pinned commit is 19 commits ahead of the fix and 0 behind. Swap it for a version requirement once a release carries the fix.

Testing

rust/difftest.sh runs both binaries over every config in testdata/ and examples/, across all three output formats and the flag combinations, requiring byte-identical stdout and matching exit codes:

pass=28 tolerated=5 fail=0

The 5 tolerated are all testdata/invalid.yaml: both reject it with the same exit code, but the message comes from the underlying YAML library and differs. Plus 13 unit tests in augur-core, and go test ./... still passes its 62.

make rust-build   make rust-test   make rust-wasm   make difftest

What this does not do

This adds an implementation; it does not replace anything. The Go CLI, library API, goreleaser config and Docker image are untouched.

Divergences still unreconciled, each needing a decision before the Rust CLI could stand in for the Go one β€” documented in rust/README.md:

case Go Rust
0644, 010 420, 8 "0644", "010"
duplicate keys last wins parse error
<<: merge key merged literal << key
ints > 2^53 precision lost via float64 exact

The last is arguably a Go bug worth fixing rather than copying. And the corpus is six configs β€” this list is what has been looked for, not proof of equivalence. Running a few hundred real collector configs through difftest.sh is the next thing worth doing.

The Go binary embeds the OPA interpreter, which is ~96% of a wasip1 build:
46.6 MB, of which stripping the entire CLI saves 1.6 MB. TinyGo cannot
compile OPA, so there is no way down from inside Go. Swapping the
interpreter for regorus and trimming unused features gives the same linter
in 1,184,533 bytes (330 KB brotli) -- a 39x reduction, which is the
difference between a viable browser payload and not.

rules/policy/ stays the single source of truth: augur-core/build.rs walks
it at compile time, so a new .rego file is picked up by both
implementations without touching Rust.

Two behaviours had to be reproduced rather than fixed, because diverging
changes lint results:

- go-yaml v2 resolves yes/no/on/off as booleans (YAML 1.1); Rust YAML
  crates follow YAML 1.2 and leave them strings. Uncoerced, a config with
  `insecure_skip_verify: yes` stops matching `== true` and OTEL-032 goes
  unreported -- a silently missed TLS finding. Keys are coerced too,
  because go-yaml does: a bare `y:` key becomes `true`.
- OPA leaves an expression undefined when a builtin hits a runtime type
  error and continues; regorus aborts by default. An empty section
  (`grpc:` with no body) reaches object.get as null and takes the whole
  run down -- 14 findings become 0. set_strict_builtin_errors(false)
  restores OPA's behaviour.

regorus is pinned to a commit rather than 0.11.0: the released crate
predates the fix for function calls through import aliases
(microsoft/regorus#747, fixed by #769), which the policies rely on via
`import data.lib`.

rust/difftest.sh runs both binaries over the corpus across all output
formats and flag combinations, requiring byte-identical stdout and
matching exit codes: 28 pass, 5 tolerated (parse-error wording), 0 fail.
Remaining unreconciled divergences are listed in rust/README.md.

Nothing in the Go tree changes; `go test ./...` still passes 62 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant