An obfuscator for Nimony, built as a test article: a program-analysis that claims to understand code by its execution structure rather than its identifier names (or its source shape) should behave identically before and after obfuscation. This produces the adversarial input for that test.
It works entirely on the compiler's own IR — parsed or typed NIF/AIF — and
never on source text. Because it rides on the token tree rather than characters,
it inherently cannot corrupt runtime data: strings, char literals and comments
are Str/Char tokens, not identifiers, so they are never rewritten. (The old
source-text name-mangler has been removed; this subsumes it.)
obfnif reads a .nif/.aif, rewrites it as a balanced token tree, and writes
it back in place — a standalone filter in the same family as nimony's own
nifler/hexer passes, i.e. literally a compiler-pipeline plugin. It auto-detects
which of the two layers a file is and adapts every rewrite to that dialect:
-
Parsed (
.p.nif/.nif, dialectnim-parsed) — the untyped treenifler/aowlparseremit from source. Identifiers are plainIdenttokens, so renaming is by spelling: every name the modules declare (proc/type/param/var/let/const/enum-field …) is mapped to an opaque token under one shared map and every matching occurrence is rewritten. Foreign names (stdlib, keywords, imports) are left alone. The result re-feedsnimsemand behaves identically. -
Typed (
.s.nif/.s.aif) — the sem-checked tree the interpreter runs. Identifiers areSymbols carryingbase.disamb.module, so renaming is symbol-precise: a definition and every cross-module use collapse to one canonical key and get the same opaque base, while re-homed generic instantiations, lifetime hooks, operators and locals are provably untouched.
On top of renaming it weaves in behaviour-preserving control flow and destroys provenance. Each technique is dialect-aware — the emitted node is valid in whichever layer the file is:
| flag | effect |
|---|---|
(default) / --no-rename |
rename declared identifiers/symbols to opaque tokens |
--wrap-opaque |
wrap a statement as if TRUE: S (typed) / if TRUE: S else: S (parsed) |
--dead-else |
give the opaque if a dead else that never runs (typed) |
--dead-guard |
precede a statement with a never-firing if FALSE: branch |
--opaque-pred |
make the injected TRUE/FALSE computed constants, e.g. and(true, not false), so a naive "is-it-literally-true?" scan is defeated |
--junk-discard |
inject side-effect-free discard <opaque-bool> no-op statements |
--nest |
wrap a statement in redundant stmts nesting |
--strip-info |
zero every token's line-info — annihilates source shape and provenance at the IR level |
--wrap-rate:N |
apply the per-statement injections on every Nth statement |
--name-style:opaque|confuse |
o0,o1,… (default) or a visually-confusable l/I/1/i/j soup |
--all |
enable the full recommended set (rename + every technique) |
--parsed / --typed |
force the layer instead of auto-detecting it |
Control flow is only ever injected around genuine executable statements —
never around declarations or directives — so moving a statement into an injected
scope can never hide an import, a let, or a type. Every injected branch
takes the same arm on every run and every discard is a no-op, so execution
structure — the set of decisions that actually vary — is preserved exactly.
A sound structure-based analysis behaves identically before and after; names and
shape give it zero help.
# build (needs nimony's NIF libs on the path)
nim c -p:/home/savant/nimony/src/lib obfnif.nim
# parsed layer — obfuscate the untyped tree, then let nimsem re-check it
nifler p mod.nim mod.p.nif
obfnif --all keep.txt mod.p.nif
# typed layer — obfuscate the sem-checked tree the interpreter/backends consume
obfnif --wrap-opaque --dead-else --dead-guard --opaque-pred --strip-info keep.txt mod.s.nifFiles are rewritten in place and share one rename map, so a call in one module
still resolves to the renamed definition in another — pass every module of a
program together. The keep-list is one identifier per line (# starts a comment):
put keywords, stdlib names, imported symbols and the public API you ground against
there.
Behaviour preservation is checked end-to-end against nimony's own
aowli-interp: for both layers and every technique combination, the obfuscated
IR (re-nimsem'd in the parsed case) produces byte-for-byte identical program
output, including across a multi-module program whose exported symbols are
renamed under the shared map.
📖 Full docs → aoughwl.github.io/docs/obfuscate