A bug every web dev has shipped, caught by a compiler that refuses to make human assumptions — and wired into a normal JS workflow.
Press → to break something.
function withdraw(balance, amount) {if (amount <= balance)return balance - amount;return balance;}
Two unit tests pass. These tests will never fail on this bug — the inputs they check are all fine. The failure comes from somewhere else…
A human reads amount as "a positive number".
The compiler reads it as "every integer that exists".
-- a withdrawal never grows your balance:theorem never_grows (balance amount : Int) :withdraw balance amount ≤ balance
No test values. balance and amount are variables over all integers — infinitely many pairs, one claim.
#eval withdraw 0 (-50)50 ← a zero balance became £50. Alice stole money.
$ lean Mini.leanerror: omega could not prove the goal:a possible counterexample may satisfy:b ≤ -1 ← the amount is NEGATIVEwhere a := balance, b := amount
Nobody typed −£50 as a test case. The failed proof derived that negative amounts are exactly where it breaks.
A failing test says "this one input broke". A rejected proof says the shape of every breaking input — which is why the fix is mechanical.
def withdraw (balance amount : Int) : Int :=if 0 ≤ amount ∧ amount ≤ balancethen balance - amount else balance-- theorem unchanged:theorem never_grows ... := byunfold withdraw; split <;> omega
$ lean Mini.lean✓ no output — silence IS the proof#print axioms never_grows[propext, Quot.sound] ← no sorry: no fake proof
Proved for every pair of integers — and the axiom audit means a future "temporary sorry" fails the build instead of faking green.
if (abs.startsWith(UPLOAD_DIR)) // looks fine
A .. buried mid-path. You'd unit-test a.png and ../../etc/passwd — nobody tests a .. in the middle.
-- careless guard accepts?true-- where does it really resolve?["srv", ".ssh", "id_rsa"] ← OUT of the vault-- the shipped guard (resolve FIRST)?false ✓ rejected
Shout a filename. Whatever you say — the theorem already covers it.
All six escapes share one shape: a .. right after the managed prefix — the exact condition the proof isolates. We never picked them.
The sweep is exhaustive for the slice. The theorem is what covers the infinity beyond it — by induction, not enumeration.
Lean never ships. It runs at build time, beside your tests — a linter that can read your intentions.
The theorem's statement is the code review: "whatever the guard accepts resolves inside the vault." Argue about the claim, not the vibes. If the JS matches the claim, the JS is safe.
The witness the proof produced runs in node --test against the real function. Refactor path.resolve away and CI goes red — the theorem guards the code through its counterexample.
lake build + #print axioms in CI: a sorry "proof" fails the build like a failing test. The certainty can't rot quietly between reviews.
✔ accepts a real file inside the managed dir✔ rejects the Lean counterexample (mid-path ..)✔ rejects sibling dir "uploads-evil"ℹ pass 4 · fail 0
| layer | guarantee |
|---|---|
| theorems ↔ model | certain — compiler-checked, zero sorry. True for every input in the model's universe. |
| model ↔ JS | pinned, not proven — the same witness runs in Node; a refactor that diverges the code from the model fails CI. You still review the model by eye. |
| known gaps | open — symlinks (statSync follows links), POSIX only. Named in the README, not hidden by the green check. |
Lean turns "is this safe?" into "is my model faithful?" — a small, attackable question instead of a vibe.
$ curl https://elan.lean-lang.org/elan-init.sh -sSf | sh # toolchain$ lean Mini.lean # 9 lines, seconds- run: (cd proofs && lake build) # one CI line, next to node --test
Unit tests check what you imagined.
Proofs check what's possible.
Model: proofs/Uploads.lean · pinned by uploads.test.js