v0.0.128: WASM call translator critical safety fixes (#475 PR 1/2)#566
Conversation
Closes the three Critical findings from #475 (filed during PR #474's calls.py decomposition review) — all real safety / correctness holes that have been sitting since mid-April. The seven Major findings ship separately as PR 2 to keep this PR's review surface focused on the safety-tier fixes. Fixes ----- vera/wasm/calls_strings.py `_translate_char_code` (#475 finding 3, 🔴 Critical, memory-safety hole) - Pre-fix: no bounds check before `i32.load8_u`. The placeholder `_ = len_s # reserved for future bounds checking` documented the gap. Out-of-range indices read arbitrary WASM linear memory at `ptr_s + (wrapped index)` — a real memory-safety issue. - Post-fix: bounds check operates in i64 space (`idx < 0 || idx >= len_s_i64`) and traps with `unreachable` *before* narrowing to i32. The in-i64 ordering matters: huge positive i64 values would otherwise wrap to small in-range-looking i32 values and silently bypass the check. - Tests: TestCharCodeBoundsCheck475 (5 cases) — in-range, negative, at-length, huge-positive, last-valid-index. vera/wasm/calls_strings.py `_translate_string_slice` (#475 finding 2, 🔴 Critical, silently-wrong output) - Pre-fix: no clamping at all. The same `_ = len_s` placeholder pattern. Indices were narrowed via `i32.wrap_i64` first; large positive i64 values silently turned into negative i32 values and drove the byte-copy loop into out-of-range memory or produced garbled output. - Post-fix: clamp in i64 space (via the new `_clamp_i64_to_range_then_wrap` helper that widens len_s to i64, clamps via nested if/else, then wraps) before narrowing. Negative starts clamp to 0; ends past length clamp to length; swapped indices produce empty slices; huge positive indices clamp to length cleanly. - Tests: TestStringSliceClampBefore475 (5 cases) — normal slice, negative start, end-beyond-length, swapped indices, huge positive start. vera/wasm/calls_handlers.py `_translate_handle_exn` (#475 finding 1, 🔴 Critical, invalid-WAT) - Pre-fix: catch-arm result type was inferred only when `clause.body` was an `ast.Block`. Expression-bodied handlers (e.g. `throw(@string) -> None`, `throw(@int) -> @Int.0 + 1`) left `result_wt = None` and the emitted WAT omitted the `(result T)` annotation, producing invalid WAT that failed validation when the body type was anything other than Unit. - Post-fix: use `_infer_expr_wasm_type` (which handles all Expr types including `ast.Block` at line 163 of inference.py) for both the catch clause and the body. Clean three-line change. - Tests: TestExpressionBodiedExnHandler475 (3 cases) — `Option`-returning, `Int`-returning, trap-path catch arms. Shared infrastructure --------------------- vera/wasm/calls_strings.py - New `_clamp_i64_to_range_then_wrap(max_local_i64) -> list[str]` helper on `CallsStringsMixin`. Emits the canonical "clamp i64 to [0, max] then wrap to i32" WAT sequence using a fresh i64 temp local. Used by both `_translate_string_slice` and `_translate_char_code`. Will be promoted to a shared location (probably `helpers.py`) when PR 2 fixes finding 4 (`array_slice` same shape, in `calls_arrays.py`). Version bump v0.0.127 → v0.0.128 -------------------------------- - vera/__init__.py: 0.0.127 → 0.0.128 - pyproject.toml: 0.0.127 → 0.0.128 - README.md: 127 releases → 128, 3,638 → 3,658 tests - docs/index.html: header version badge - uv.lock: regenerated (one-line vera version line) CHANGELOG.md - New [0.0.128] - 2026-05-05 release section. Includes the three Critical fixes under ### Fixed, the new shared helper under ### Shared infrastructure, and the previously-Unreleased byte-arithmetic spec note from #565 / #551 closure under ### Documentation (rolled in per the standard release treadmill). - New [0.0.128] link reference; bumped [Unreleased] base. HISTORY.md - Single-line v0.0.128 row per the hardened format ("**WASM call translator critical safety fixes** ([#475]())") — one bolded outcome clause, one link, no em-dashes, no secondary clauses. KNOWN_ISSUES.md - #475 row narrowed to the seven Major findings remaining; Critical findings struck from the description. ROADMAP.md - Campaign tracker (line 25): append "v0.0.128 shipped PR 1 of 2 for #475" with named Critical findings; count stays at "seven remain" because #475 is still open with Major findings. - Priority table row 1: rewritten to "PR 2 of 2 — seven Major findings" with each Major finding briefly named. #475 stays at row 1 (reflecting "Major correctness debt still outstanding"). - "By the numbers" footer: 3,645 → 3,658 tests. - TESTING.md test counts updated: * Tests headline: 3,645 → 3,658 (3,644 passed, 14 skipped). * test_codegen.py row: 998 → 1,011 / 14,131 → 14,432. Verification ------------ - 13 new tests in TestExpressionBodiedExnHandler475 + TestStringSliceClampBefore475 + TestCharCodeBoundsCheck475 all pass. - 3,545 in the full pytest suite (excluding browser parity); no regressions. - mypy clean; doc-counts / limitations-sync / version-sync all green. Co-Authored-By: Claude <noreply@anthropic.invalid>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughFixes three critical WASM call-translator issues: clamp string slice indices in i64 before narrowing, perform i64-space bounds checks for string char-code before load, and ensure expression-bodied ChangesWASM Call Translator Safety Fixes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~40 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #566 +/- ##
=======================================
Coverage 90.93% 90.94%
=======================================
Files 59 59
Lines 22388 22418 +30
Branches 259 259
=======================================
+ Hits 20358 20387 +29
- Misses 2023 2024 +1
Partials 7 7
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CHANGELOG.md`:
- Line 15: The changelog is inaccurate: in vera/wasm/calls_handlers.py within
_translate_handle_exn the body type is still inferred using
_infer_block_result_type(expr.body); update the implementation to call
_infer_expr_wasm_type for the handler body as well (i.e., replace the use of
_infer_block_result_type on expr.body with _infer_expr_wasm_type) so the code
matches the release note; ensure the change affects both the catch clause and
the handler body inference sites in _translate_handle_exn.
In `@ROADMAP.md`:
- Line 283: Update the release-count footer string "127 tagged releases (as of
v0.0.127)" in ROADMAP.md to "128 tagged releases (as of v0.0.128)" and make the
matching edits in HISTORY.md (there are three roll-up/footer locations called
out in the comment) so the tagged-release total and the version token (v0.0.127
→ v0.0.128) are incremented consistently across all instances.
In `@vera/wasm/calls_strings.py`:
- Around line 245-247: The source string local (ptr_s) must be rooted before
performing the subsequent call $alloc to avoid GC reclaiming a heap string;
update the code that builds the instructions (the block that appends s_instrs
then "local.set {len_s}" and "local.set {ptr_s}") to insert a call to
gc_shadow_push for ptr_s immediately before emitting the "call $alloc"
instruction and ensure you emit the matching gc_shadow_pop after the
allocation/copy completes; do the same change for the similar sequence around
the other block (the one referenced at lines 283-287) so every heap pointer live
across call $alloc is pushed to the GC shadow (use the existing
gc_shadow_push/gc_shadow_pop helpers).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6ff7ae15-0b57-4188-822d-93382f2399b4
⛔ Files ignored due to path filters (6)
docs/index.htmlis excluded by!docs/**docs/index.mdis excluded by!docs/**docs/llms-full.txtis excluded by!docs/**docs/llms.txtis excluded by!docs/**docs/sitemap.xmlis excluded by!docs/**uv.lockis excluded by!**/*.lock,!uv.lock
📒 Files selected for processing (11)
CHANGELOG.mdHISTORY.mdKNOWN_ISSUES.mdREADME.mdROADMAP.mdTESTING.mdpyproject.tomltests/test_codegen.pyvera/__init__.pyvera/wasm/calls_handlers.pyvera/wasm/calls_strings.py
Three findings; one functional bug, one accuracy fix, one release-count drift across two files. vera/wasm/calls_strings.py `_translate_string_slice` — GC root for ptr_s (finding 3, real GC bug) - Pre-fix: `ptr_s` (the source string pointer) was set into a WAT local at line ~247, then `call $alloc` at line ~285 could trigger GC. The conservative collector only sees pointers stored on the GC shadow stack via `gc_shadow_push`, NOT those living solely in WAT locals — so during a GC fired by the destination allocation, the source string could be reclaimed while still in use by the byte-copy loop at line ~301. Sibling sites in `calls_arrays.py` (e.g. `_translate_array_map` at line 672) push the source pointer on entry; this site missed the convention. - Post-fix: insert `gc_shadow_push(ptr_s)` immediately after the ptr/len destructure, before any subsequent allocation. Auto-pop at function exit via the standard $gc_sp save/restore in the function epilogue (no `gc_shadow_pop` helper exists; pushes unwind together when the function returns). - Inline comment documents the rooting requirement so a future reader sees why the push is there. vera/wasm/calls_handlers.py `_translate_handle_exn` — body inference also uses _infer_expr_wasm_type (finding 1, accuracy fix) - Pre-this-fix the catch-clause inference used the right helper but the body-fallback still used `_infer_block_result_type`, even though the CHANGELOG text claimed both call sites were updated. `expr.body` is statically typed `Block` (ast.py:481) so the two helpers produce identical output for it — no functional difference — but the inconsistency between code and release note was real. - Post-fix: both inference sites now call `_infer_expr_wasm_type`. Comment noting the type guarantee added so a future reader doesn't unify them differently. ROADMAP.md / HISTORY.md — release-count strings (finding 2) - ROADMAP.md line 3: "Vera v0.0.127 delivers..." → "v0.0.128" - ROADMAP.md line 283: "127 tagged releases (as of v0.0.127)" → "128 tagged releases (as of v0.0.128)" - HISTORY.md line 295: by-the-numbers table column header v0.0.127 (29 Apr) → v0.0.128 (5 May). Row "Tests" already bumped 3,638 → 3,658 in the prior PR; release count moves with it. - HISTORY.md line 305: footer "127 tagged releases, 49 active development days" → "128, 54" (49 + 5 days from 2026-04-29 to 2026-05-05). Verification - 13 TestExpressionBodiedExnHandler475 / TestStringSliceClampBefore475 / TestCharCodeBoundsCheck475 tests still pass after the ptr_s root + handler-body helper switch. - mypy clean; doc-counts / limitations-sync / version-sync all green. Co-Authored-By: Claude <noreply@anthropic.invalid>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
HISTORY.md (1)
3-3:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUpdate the header development-day count to match the footer.
Line 3 states "across 48 active development days" whilst line 305 (the roll-up footer) shows "54 active development days". Both describe the same timeline ("from initial commit through Stage 11"), so the counts must agree. The footer count (54) is authoritative as it was updated in this PR; line 3 should be updated to match.
📅 Proposed fix
-How the Vera compiler was built, from initial commit through Stage 11, across 48 active development days. +How the Vera compiler was built, from initial commit through Stage 11, across 54 active development days.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@HISTORY.md` at line 3, Update the header sentence that reads "How the Vera compiler was built, from initial commit through Stage 11, across 48 active development days." to use the authoritative footer count of 54 active development days so it matches the footer; locate the header string in HISTORY.md and change "48" to "54".
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@HISTORY.md`:
- Line 3: Update the header sentence that reads "How the Vera compiler was
built, from initial commit through Stage 11, across 48 active development days."
to use the authoritative footer count of 54 active development days so it
matches the footer; locate the header string in HISTORY.md and change "48" to
"54".
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 9f07a8dc-3888-4d6e-8252-b230861aa96f
📒 Files selected for processing (4)
HISTORY.mdROADMAP.mdvera/wasm/calls_handlers.pyvera/wasm/calls_strings.py
…drift Single-line fix. HISTORY.md line 3 (header) said "48 active development days"; line 305 (footer) said "54". Pre-existing drift (header was 48, footer 49 at v0.0.127); my v0.0.128 update bumped the footer to 54 to match the new release date but didn't catch the header. HISTORY.md - Line 3: 48 → 54 to match the authoritative footer count. Co-Authored-By: Claude <noreply@anthropic.invalid>
|
@coderabbitai (re: HISTORY.md outside-diff finding) Verified — pre-existing drift, header was 48, footer 49 at v0.0.127; my v0.0.128 update bumped the footer to 54 to match the new release date but didn't catch the header. Fixed in commit |
|
Tip For best results, initiate chat on the files or code changes.
|
v0.0.128 — three Critical safety fixes from #475 (PR 1 of 2). Closes the memory-read-OOB hole and two correctness bugs that have been outstanding since mid-April.
Closes #475 (partial — Critical findings 1, 2, 3 of 10). The seven Major findings ship as PR 2 to keep this PR's review surface focused on safety-tier work.
Summary
Three pre-existing translator bugs surfaced by CodeRabbit during PR #474's calls.py decomposition review. Each was concentrated in one method, fixable with a small local change, and shares the architectural pattern "validate in i64 before narrowing to i32 — pre-narrow validation lets huge positive i64 values silently turn into in-range-looking i32 values and bypass the check."
_translate_char_codei32.load8_u— out-of-range index reads arbitrary memory_translate_string_slicei32.wrap_i64first, then... nothing_translate_handle_exnast.Blockbodies — expression-bodied handlers emit invalid WATEach method had a placeholder
_ = len_s # reserved for future bounds checking(or equivalent) that documented the gap. The "TODO that passes the linter" pattern is technical debt that takes external review (Rabbit, in this case) to surface; lesson noted in the commit message.Fix shape
Finding 3 (
_translate_char_code): bounds check operates in i64 (idx < 0 || idx >= len_s_i64) and traps withunreachablebefore narrowing to i32.Finding 2 (
_translate_string_slice): clamp in i64 space (via the new_clamp_i64_to_range_then_wraphelper that widenslen_sto i64, clamps via nested if/else, then wraps) before narrowing. Negative starts clamp to 0; ends past length clamp to length; swapped indices produce empty slices.Finding 1 (
_translate_handle_exn): replace theisinstance(clause.body, ast.Block)guard with_infer_expr_wasm_type(clause.body)which handles allast.Exprtypes uniformly (includingast.Blockat line 163 ofinference.py).Shared infrastructure
New
_clamp_i64_to_range_then_wrap(max_local_i64) -> list[str]helper onCallsStringsMixin. Used by both_translate_string_sliceand_translate_char_code. Will promote to a shared location when PR 2 fixes finding 4 (array_slicesame shape, incalls_arrays.py).Tests
13 new tests across three classes, all passing:
TestCharCodeBoundsCheck475(5) — in-range / negative / at-length / huge-positive / last-valid-indexTestStringSliceClampBefore475(5) — normal / negative-start / end-beyond-length / swapped / huge-positiveTestExpressionBodiedExnHandler475(3) —Option-returning,Int-returning, trap-path catch armsDocumentation
[0.0.128]section. Includes the previously-Unreleased byte-arithmetic spec note from Close #551 as not-a-bug; track @Byte arithmetic speculatively as #564 #565 / @Byte subtraction silent underflow (follow-up to #520) #551 closure (rolled in per the standard release treadmill).Test plan
pytest tests/test_codegen.py::TestCharCodeBoundsCheck475 tests/test_codegen.py::TestStringSliceClampBefore475 tests/test_codegen.py::TestExpressionBodiedExnHandler475— 13 passpytest tests/ -q --ignore=tests/test_browser.py— 3,545 pass, 14 skipped (no regressions)mypy vera/— cleanpython scripts/check_doc_counts.py— 3,658 tests, 82 conformance, 33 examplespython scripts/check_limitations_sync.py— 33 / 15 / 2python scripts/check_version_sync.py— v0.0.128 across 4 filespython scripts/check_spec_examples.py— all parseable spec blocks passCo-Authored-By: Claude noreply@anthropic.invalid
Summary by CodeRabbit