feat(rust): add a Rust implementation sharing the Rego rules - #97
Open
starkross wants to merge 1 commit into
Open
feat(rust): add a Rust implementation sharing the Rego rules#97starkross wants to merge 1 commit into
starkross wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
wasip1module 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):
make wasm)make rust-wasm)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 jsonoutput 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β theaugurbinary. Flags and all three output formats match the Go CLI.rules/policy/stays the single source of truth.augur-core/build.rswalks it at compile time, so a new.regofile 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/yamlwraps go-yaml v2, which resolvesyes/no/on/offas booleans. Rust YAML crates follow the YAML 1.2 core schema and leave them strings. Uncoerced, a config withinsecure_skip_verify: yesstops matching== true: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 becomestrue, whichsigs.k8s.io/yamlthen 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 inexamples/bad.yaml) parses as null, reachesobject.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 withopa eval --strict-builtin-errors, which reproduces the regorus error exactly."findings": nullfor 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.0was published 2026-07-21; the fix for function calls through import aliases (microsoft/regorus#747, via #769) merged 2026-07-23. The policies calllib.pipeline_receivers(t)afterimport data.libat 48 sites, which the released crate rejects withcould 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.shruns both binaries over every config intestdata/andexamples/, across all three output formats and the flag combinations, requiring byte-identical stdout and matching exit codes: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 inaugur-core, andgo test ./...still passes its 62.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:0644,010420,8"0644","010"<<:merge key<<keyThe 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.shis the next thing worth doing.