From f05b0450b1493d16b6518e7cee11b1b09b39beb4 Mon Sep 17 00:00:00 2001 From: Hitalo Souza Date: Fri, 26 Jun 2026 15:43:31 -0300 Subject: [PATCH] v.gen.wasm: floor-safe wasm-opt/wasm-validate feature allowlist + memory-contract test (#27526) --- vlib/v/gen/wasm/features.v | 228 ++++++++++++++++++ vlib/v/gen/wasm/features_test.v | 32 +++ vlib/v/gen/wasm/gen.v | 71 ++++-- vlib/v/gen/wasm/mem.v | 15 ++ .../gen/wasm/tests_decompile/must_have_test.v | 22 ++ .../no_frontend_free_under_wasm.vv | 23 ++ ...no_frontend_free_under_wasm.wasm.must_have | 1 + ...rontend_free_under_wasm.wasm.must_not_have | 2 + .../no_frontend_free_under_wasm_autofree.vv | 24 ++ ...nd_free_under_wasm_autofree.wasm.must_have | 1 + ...ree_under_wasm_autofree.wasm.must_not_have | 2 + 11 files changed, 404 insertions(+), 17 deletions(-) create mode 100644 vlib/v/gen/wasm/features.v create mode 100644 vlib/v/gen/wasm/features_test.v create mode 100644 vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.vv create mode 100644 vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.wasm.must_have create mode 100644 vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.wasm.must_not_have create mode 100644 vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.vv create mode 100644 vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.wasm.must_have create mode 100644 vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.wasm.must_not_have diff --git a/vlib/v/gen/wasm/features.v b/vlib/v/gen/wasm/features.v new file mode 100644 index 000000000..947f37c78 --- /dev/null +++ b/vlib/v/gen/wasm/features.v @@ -0,0 +1,228 @@ +// Copyright (c) 2023 l-m.dev. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module wasm + +// WebAssembly feature gating for the native backend. +// +// `wasm-opt` (Binaryen) and `wasm-validate` (WABT) accept *different* flag +// spellings for the same features (e.g. Binaryen `--enable-exception-handling` +// vs WABT `--enable-exceptions`; Binaryen `--enable-nontrapping-float-to-int` +// vs WABT `--disable-saturating-float-to-int`). The logical feature set is +// therefore defined once here and rendered into each tool's own vocabulary. +// +// The default build enables only the Safari-15 "floor" features; everything +// else is opt-in via a `-d ` compile define. This keeps a default +// `-os browser`/`-os wasi` build from silently emitting opcodes past the +// baseline (the old `wasm-opt -all` enabled memory64/relaxed-SIMD/exnref/gc). + +// wasm_opt_min_version is the Binaryen version the toolchain pins +// (see cmd/tools/install_binaryen.vsh). Used only for a soft warning. +const wasm_opt_min_version = 112 + +enum WasmFeature { + reference_types + bulk_memory + multivalue + sign_ext + mutable_globals + nontrapping_f2i + simd + gc + exception_handling + tail_call + threads + memory64 + relaxed_simd + extended_const +} + +// wasm_all_features lists every modelled feature, used to compute the WABT +// validator argument set relative to its defaults. +const wasm_all_features = [WasmFeature.reference_types, .bulk_memory, .multivalue, .sign_ext, + .mutable_globals, .nontrapping_f2i, .simd, .gc, .exception_handling, .tail_call, .threads, + .memory64, .relaxed_simd, .extended_const] + +// wasm_floor_features is the always-on Safari-15 baseline. It covers everything +// the emitter produces today: ref.func/ref.null, memory.copy/fill/init, +// multi-return, sign_extendN, the mutable __vsp/__heap_base globals, and +// saturating (nontrapping) float->int casts. +const wasm_floor_features = [WasmFeature.reference_types, .bulk_memory, .multivalue, .sign_ext, + .mutable_globals, .nontrapping_f2i] + +// wasm_optin_defines maps a `-d ` compile define to the feature it enables. +const wasm_optin_defines = { + 'wasm_gc': WasmFeature.gc + 'wasm_exceptions': WasmFeature.exception_handling + 'wasm_tail_call': WasmFeature.tail_call + 'wasm_simd': WasmFeature.simd + 'wasm_threads': WasmFeature.threads + 'wasm_memory64': WasmFeature.memory64 + 'wasm_relaxed_simd': WasmFeature.relaxed_simd + 'wasm_extended_const': WasmFeature.extended_const +} + +struct FeatureFlag { + binaryen string // wasm-opt flag, e.g. '--enable-reference-types' + wabt string // WABT feature token, e.g. 'reference-types' + wabt_default_on bool // whether wasm-validate enables it without any flag +} + +fn (f WasmFeature) flag() FeatureFlag { + return match f { + .reference_types { + FeatureFlag{ + binaryen: '--enable-reference-types' + wabt: 'reference-types' + wabt_default_on: true + } + } + .bulk_memory { + FeatureFlag{ + binaryen: '--enable-bulk-memory' + wabt: 'bulk-memory' + wabt_default_on: true + } + } + .multivalue { + FeatureFlag{ + binaryen: '--enable-multivalue' + wabt: 'multi-value' + wabt_default_on: true + } + } + .sign_ext { + FeatureFlag{ + binaryen: '--enable-sign-ext' + wabt: 'sign-extension' + wabt_default_on: true + } + } + .mutable_globals { + FeatureFlag{ + binaryen: '--enable-mutable-globals' + wabt: 'mutable-globals' + wabt_default_on: true + } + } + .nontrapping_f2i { + FeatureFlag{ + binaryen: '--enable-nontrapping-float-to-int' + wabt: 'saturating-float-to-int' + wabt_default_on: true + } + } + .simd { + FeatureFlag{ + binaryen: '--enable-simd' + wabt: 'simd' + wabt_default_on: true + } + } + .gc { + FeatureFlag{ + binaryen: '--enable-gc' + wabt: 'gc' + wabt_default_on: false + } + } + .exception_handling { + FeatureFlag{ + binaryen: '--enable-exception-handling' + wabt: 'exceptions' + wabt_default_on: false + } + } + .tail_call { + FeatureFlag{ + binaryen: '--enable-tail-call' + wabt: 'tail-call' + wabt_default_on: false + } + } + .threads { + FeatureFlag{ + binaryen: '--enable-threads' + wabt: 'threads' + wabt_default_on: false + } + } + .memory64 { + FeatureFlag{ + binaryen: '--enable-memory64' + wabt: 'memory64' + wabt_default_on: false + } + } + .relaxed_simd { + FeatureFlag{ + binaryen: '--enable-relaxed-simd' + wabt: 'relaxed-simd' + wabt_default_on: false + } + } + .extended_const { + FeatureFlag{ + binaryen: '--enable-extended-const' + wabt: 'extended-const' + wabt_default_on: false + } + } + } +} + +// enabled_wasm_features returns the floor set plus any opt-in feature whose +// `-d` define was passed on the command line. +fn (g &Gen) enabled_wasm_features() []WasmFeature { + mut feats := wasm_floor_features.clone() + for define, feat in wasm_optin_defines { + if define in g.pref.compile_defines { + feats << feat + } + } + return apply_feature_implications(feats) +} + +// apply_feature_implications expands `feats` with any feature implied by another. +// Relaxed SIMD extends the SIMD/v128 feature, so requesting it must also enable +// SIMD. Otherwise wabt_validate_args() emits `--disable-simd --enable-relaxed-simd` +// and wasm-opt runs from `-mvp` without `--enable-simd`, so a `-d wasm_relaxed_simd` +// build still fails under `-wasm-validate`/`-prod`. +fn apply_feature_implications(feats []WasmFeature) []WasmFeature { + mut res := feats.clone() + if WasmFeature.relaxed_simd in res && WasmFeature.simd !in res { + res << .simd + } + return res +} + +// binaryen_feature_flags renders the feature set into `wasm-opt` flags. It +// starts from `-mvp` (all non-MVP features off) and enables only the allowlist, +// so emitting an opcode outside the set makes wasm-opt itself fail rather than +// silently optimise it through. +fn binaryen_feature_flags(feats []WasmFeature) string { + mut flags := ['-mvp'] + for feat in feats { + flags << feat.flag().binaryen + } + return flags.join(' ') +} + +// wabt_validate_args renders the feature set into `wasm-validate` arguments, +// relative to WABT's defaults: disable any default-on feature not in the set +// (e.g. `--disable-simd` on a floor build), and enable any default-off feature +// that is in the set. This gives the validator teeth - a stray above-floor +// opcode fails validation instead of passing under WABT's permissive defaults. +fn wabt_validate_args(feats []WasmFeature) []string { + mut args := []string{} + for feat in wasm_all_features { + ff := feat.flag() + enabled := feat in feats + if ff.wabt_default_on && !enabled { + args << '--disable-${ff.wabt}' + } else if !ff.wabt_default_on && enabled { + args << '--enable-${ff.wabt}' + } + } + return args +} diff --git a/vlib/v/gen/wasm/features_test.v b/vlib/v/gen/wasm/features_test.v new file mode 100644 index 000000000..7d0182c1f --- /dev/null +++ b/vlib/v/gen/wasm/features_test.v @@ -0,0 +1,32 @@ +module wasm + +fn test_relaxed_simd_implies_simd() { + // requesting relaxed SIMD on its own must also pull in the base SIMD feature + feats := apply_feature_implications([WasmFeature.relaxed_simd]) + assert WasmFeature.simd in feats + assert WasmFeature.relaxed_simd in feats +} + +fn test_relaxed_simd_does_not_duplicate_simd() { + // when SIMD is already present, the implication must not add a duplicate + feats := apply_feature_implications([WasmFeature.simd, .relaxed_simd]) + assert feats.filter(it == WasmFeature.simd).len == 1 +} + +fn test_simd_without_relaxed_is_unchanged() { + feats := apply_feature_implications([WasmFeature.simd]) + assert WasmFeature.simd in feats + assert WasmFeature.relaxed_simd !in feats +} + +fn test_relaxed_simd_renders_simd_in_tool_flags() { + feats := apply_feature_implications([WasmFeature.relaxed_simd]) + // wasm-opt must be told to enable SIMD as well as relaxed SIMD + binaryen := binaryen_feature_flags(feats) + assert binaryen.contains('--enable-simd') + assert binaryen.contains('--enable-relaxed-simd') + // wasm-validate must not disable SIMD while enabling relaxed SIMD + wabt := wabt_validate_args(feats) + assert '--disable-simd' !in wabt + assert '--enable-relaxed-simd' in wabt +} diff --git a/vlib/v/gen/wasm/gen.v b/vlib/v/gen/wasm/gen.v index 8dbbfe830..04e77c414 100644 --- a/vlib/v/gen/wasm/gen.v +++ b/vlib/v/gen/wasm/gen.v @@ -2031,33 +2031,32 @@ pub fn gen(files []&ast.File, mut table ast.Table, out_name string, w_pref &pref if out_name != '-' { os.write_file_array(out_name, mod) or { panic(err) } + // The enabled feature set (Safari-15 floor plus any `-d` opt-in) drives + // both validation and optimisation, so compute it once and share it. + feats := g.enabled_wasm_features() if g.pref.wasm_validate { - exe := $if windows { 'wasm-validate.exe' } $else { 'wasm-validate' } - if rt := os.find_abs_path_of_executable(exe) { - mut p := os.new_process(rt) - p.set_args([out_name]) - p.set_redirect_stdio() - p.run() - err := p.stderr_slurp() - p.wait() - if p.code != 0 { - eprintln(err) - g.w_error('validation failed, this should not happen. report an issue with the above messages, the webassembly generated, and appropriate code.') - } - } else { - g.w_error('${exe} not found! Try installing WABT (WebAssembly Binary Toolkit). Run `./cmd/tools/install_wabt.vsh`, to download a prebuilt executable for your platform.') - } + g.validate_wasm(out_name, feats) } if g.pref.is_prod { exe := $if windows { 'wasm-opt.exe' } $else { 'wasm-opt' } if rt := os.find_abs_path_of_executable(exe) { - // -lmu: low memory unused, very important optimisation + g.check_wasm_opt_version(rt) + // -lmu: low memory unused, very important optimisation. + // Feature flags are an explicit floor-safe allowlist (never `-all`), + // so wasm-opt cannot silently introduce opcodes past the baseline. + flags := binaryen_feature_flags(feats) res := - os.execute('${os.quoted_path(rt)} -all -lmu -c -O4 ${os.quoted_path(out_name)} -o ${os.quoted_path(out_name)}') + os.execute('${os.quoted_path(rt)} ${flags} -lmu -c -O4 ${os.quoted_path(out_name)} -o ${os.quoted_path(out_name)}') if res.exit_code != 0 { eprintln(res.output) g.w_error('${rt} failed, this should not happen. Report an issue with the above messages, the webassembly generated, and appropriate code.') } + // Re-validate AFTER optimisation: a passing pre-opt validation is + // not sufficient, wasm-opt must not have introduced any opcode past + // the enabled feature floor. + if g.pref.wasm_validate { + g.validate_wasm(out_name, feats) + } } else { g.w_error('${exe} not found! Try installing Binaryen. | Run `./cmd/tools/install_binaryen.vsh`, to download a prebuilt executable for your platform. @@ -2071,3 +2070,41 @@ pub fn gen(files []&ast.File, mut table ast.Table, out_name string, w_pref &pref eprintln('stdout output, cannot validate or optimise wasm') } } + +// validate_wasm runs `wasm-validate` over the emitted module at `out_name`, +// constraining it to the enabled feature set so that any opcode past the floor +// fails validation instead of passing under WABT's permissive defaults. +fn (mut g Gen) validate_wasm(out_name string, feats []WasmFeature) { + exe := $if windows { 'wasm-validate.exe' } $else { 'wasm-validate' } + if rt := os.find_abs_path_of_executable(exe) { + mut p := os.new_process(rt) + mut vargs := wabt_validate_args(feats) + vargs << out_name + p.set_args(vargs) + p.set_redirect_stdio() + p.run() + err := p.stderr_slurp() + p.wait() + if p.code != 0 { + eprintln(err) + g.w_error('validation failed, this should not happen. report an issue with the above messages, the webassembly generated, and appropriate code.') + } + } else { + g.w_error('${exe} not found! Try installing WABT (WebAssembly Binary Toolkit). Run `./cmd/tools/install_wabt.vsh`, to download a prebuilt executable for your platform.') + } +} + +// check_wasm_opt_version emits a soft warning if the resolved wasm-opt is older +// than the version the toolchain pins. It never fails the build (a hard pin with +// checksums belongs to the build/release pipeline, not the backend). +fn (mut g Gen) check_wasm_opt_version(exe string) { + res := os.execute('${os.quoted_path(exe)} --version') + if res.exit_code != 0 { + return + } + // `wasm-opt --version` prints e.g. "wasm-opt version 108" + ver := res.output.all_after_last(' ').trim_space().int() + if ver != 0 && ver < wasm_opt_min_version { + eprintln('warning: wasm-opt version ${ver} is older than the pinned minimum (${wasm_opt_min_version}); `-prod` output may differ. Run `./cmd/tools/install_binaryen.vsh` to update.') + } +} diff --git a/vlib/v/gen/wasm/mem.v b/vlib/v/gen/wasm/mem.v index 5546a58aa..feb3377bd 100644 --- a/vlib/v/gen/wasm/mem.v +++ b/vlib/v/gen/wasm/mem.v @@ -3,6 +3,21 @@ // that can be found in the LICENSE file. module wasm +// Memory contract for the native WebAssembly backend +// --------------------------------------------------- +// `-b wasm` selects `-gc none` automatically (every non-C backend defaults to +// no_gc in v.pref.default), and autofree is off by default. Crucially, autofree +// code generation lives only in the C backend (v/gen/c/autofree.v); this backend +// never sees frontend-inserted free/drop calls, so the AST it lowers is "natural". +// +// Today the only memory mechanism is the shadow stack (__vsp, grows down). +// `g.func.drop()` calls in this backend are WebAssembly operand-stack drops, not +// memory frees. There is no malloc/free/GC. +// +// This invariant is load-bearing: a future manual allocator (__v_alloc/__v_free) +// and explicit dispose() will be the sole heap-management entry points, and must +// not collide with frontend-inserted frees. The no-frontend-free property is +// regression-guarded by tests_decompile/no_frontend_free_under_wasm.vv. import wasm import v.ast import v.gen.wasm.serialise diff --git a/vlib/v/gen/wasm/tests_decompile/must_have_test.v b/vlib/v/gen/wasm/tests_decompile/must_have_test.v index c9f3ae65e..094d8f19f 100644 --- a/vlib/v/gen/wasm/tests_decompile/must_have_test.v +++ b/vlib/v/gen/wasm/tests_decompile/must_have_test.v @@ -24,6 +24,7 @@ fn test_wasm_when_decompiled_must_have() { vv_files_loop: for vv_file in vv_files { bench.step() must_have_path := vv_file.replace('.vv', '.wasm.must_have') + must_not_have_path := vv_file.replace('.vv', '.wasm.must_not_have') vv_file_name := os.file_name(vv_file).replace('.vv', '') full_vv_path := os.real_path(vv_file).replace(os.path_separator, '/') relative_vv_path := full_vv_path.replace(vroot + '/', '') @@ -79,6 +80,27 @@ fn test_wasm_when_decompiled_must_have() { continue } + // Optional `.wasm.must_not_have` companion: each non-empty line is a + // pattern that must NOT appear in the decompiled output (negative assertion). + forbidden_lines := os.read_lines(must_not_have_path) or { [] } + mut forbidden_patterns := []string{} + for idx_forbidden_line, fline in forbidden_lines { + if fline == '' { + continue + } + if does_line_match_one_of_generated_lines(fline, generated_wasm_lines) { + forbidden_patterns << fline + eprintln('${must_not_have_path}:${idx_forbidden_line + 1}: forbidden match error:') + eprintln('`${cmd_decompile}` unexpectedly produced a forbidden line:') + eprintln(term.colorize(term.red, fline)) + } + } + if forbidden_patterns.len > 0 { + eprintln('> forbidden match patterns: ${forbidden_patterns.len}') + bench.fail() + continue + } + bench.ok() eprintln(bench.step_message_ok(relative_vv_path)) } diff --git a/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.vv b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.vv new file mode 100644 index 000000000..4f3c26a1a --- /dev/null +++ b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.vv @@ -0,0 +1,23 @@ +// vtest vflags: -os wasi +// Memory-contract fixture: `-b wasm` must not emit frontend-inserted frees. +// Uses only currently-supported constructs (scalars, structs, `+` string concat). +struct Point { + x int + y int +} + +fn make_point(a int, b int) Point { + return Point{ + x: a + b + y: a - b + } +} + +pub fn greet_len() int { + a := 'hello' + b := a + ' world' + p := make_point(2, 3) + return b.len + p.x + p.y +} + +println(greet_len()) diff --git a/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.wasm.must_have b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.wasm.must_have new file mode 100644 index 000000000..a0661622b --- /dev/null +++ b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.wasm.must_have @@ -0,0 +1 @@ +export function main_greet_len():int { diff --git a/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.wasm.must_not_have b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.wasm.must_not_have new file mode 100644 index 000000000..d7043d2b9 --- /dev/null +++ b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm.wasm.must_not_have @@ -0,0 +1,2 @@ +free +autofree diff --git a/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.vv b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.vv new file mode 100644 index 000000000..016f148ca --- /dev/null +++ b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.vv @@ -0,0 +1,24 @@ +// vtest vflags: -os wasi -autofree +// Same as no_frontend_free_under_wasm.vv, but with -autofree explicitly on: +// proves the flag is inert on the wasm backend (autofree codegen is C-only), +// so no free/drop is injected even when requested. +struct Point { + x int + y int +} + +fn make_point(a int, b int) Point { + return Point{ + x: a + b + y: a - b + } +} + +pub fn greet_len() int { + a := 'hello' + b := a + ' world' + p := make_point(2, 3) + return b.len + p.x + p.y +} + +println(greet_len()) diff --git a/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.wasm.must_have b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.wasm.must_have new file mode 100644 index 000000000..a0661622b --- /dev/null +++ b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.wasm.must_have @@ -0,0 +1 @@ +export function main_greet_len():int { diff --git a/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.wasm.must_not_have b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.wasm.must_not_have new file mode 100644 index 000000000..d7043d2b9 --- /dev/null +++ b/vlib/v/gen/wasm/tests_decompile/no_frontend_free_under_wasm_autofree.wasm.must_not_have @@ -0,0 +1,2 @@ +free +autofree -- 2.39.5