From 69ad42db326f4820c85643145676e47b04608156 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 13 Jun 2026 13:26:02 +0200 Subject: [PATCH 1/2] fix(wasm_optimize): resolve symlinked inputs via loom_wrapper (#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wasm_optimize runs loom.wasm under `wasmtime run --dir=.`. A fetched or adopted component input (e.g. an http_file-sourced component) is staged by Bazel as a symlink whose target escapes the exec-root preopen, so wasmtime's WASI sandbox (cap-std) refuses to follow it and loom reports "Input file not found" — while wasm_validate on the same target succeeds because wasm-tools is a native binary, not sandboxed. Add a pure-Go //tools/loom_wrapper (mirroring //tools/wasmsign2_wrapper, as the existing TODO suggested) that resolves each path argument with EvalSymlinks and preopens the resolved real directories for wasmtime. loom.wasm is passed as the first argument (wasmtime opens the module natively, so it needs no WASI mount); only wasmtime is located via runfiles. Verified: loom_wrapper compiles and wasm_optimize analyses. End-to-end run against a fetched component still needs confirmation on the reporter's falcon setup (offered in the issue). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/loom_wrapper/BUILD.bazel | 25 +++++ tools/loom_wrapper/main.go | 163 +++++++++++++++++++++++++++++++++ wasm/private/wasm_optimize.bzl | 34 ++++--- 3 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 tools/loom_wrapper/BUILD.bazel create mode 100644 tools/loom_wrapper/main.go diff --git a/tools/loom_wrapper/BUILD.bazel b/tools/loom_wrapper/BUILD.bazel new file mode 100644 index 00000000..5fedd3df --- /dev/null +++ b/tools/loom_wrapper/BUILD.bazel @@ -0,0 +1,25 @@ +"""Cross-platform wrapper for the LOOM WASM optimizer component. + +This wrapper executes the pre-built loom.wasm component via wasmtime, +resolving symlinks to real paths and preopening the resolved directories so +LOOM can read fetched/adopted component inputs that Bazel stages as symlinks +(issue #490). Mirrors //tools/wasmsign2_wrapper. +""" + +load("@rules_go//go:def.bzl", "go_binary") + +package(default_visibility = ["//visibility:public"]) + +# Wrapper binary that executes loom.wasm with symlink/path resolution. +# loom.wasm itself is supplied by the calling rule as the first argument +# (wasmtime opens the module natively); only wasmtime is located via runfiles. +go_binary( + name = "loom_wrapper", + srcs = ["main.go"], + data = [ + "@wasmtime_toolchain//:wasmtime", + ], + pure = "on", # Pure Go for cross-platform compatibility + visibility = ["//visibility:public"], + deps = ["@rules_go//go/runfiles"], +) diff --git a/tools/loom_wrapper/main.go b/tools/loom_wrapper/main.go new file mode 100644 index 00000000..9d4ea677 --- /dev/null +++ b/tools/loom_wrapper/main.go @@ -0,0 +1,163 @@ +package main + +import ( + "log" + "os" + "os/exec" + "path/filepath" + + "github.com/bazelbuild/rules_go/go/runfiles" +) + +// loom_wrapper runs the loom.wasm optimizer component under wasmtime. +// +// It resolves symlinked file paths in the loom command to their real +// locations and preopens those real directories for wasmtime. Bazel stages +// a fetched/adopted input (e.g. an http_file-sourced component) as a symlink +// whose target escapes the exec root; wasmtime's WASI sandbox (cap-std) +// refuses to follow such a symlink under a plain `--dir=.` preopen, so loom +// reports "Input file not found" (issue #490). Resolving the symlink on the +// host and preopening the resolved directory gives loom a real directory to +// read from. This mirrors tools/wasmsign2_wrapper, which solves the identical +// problem for the wasmsign2 component. +// +// Usage: +// +// loom_wrapper [args...] +// +// e.g. loom_wrapper bazel-out/.../loom.wasm optimize -o [flags] +// +// The loom.wasm module path is passed by the Bazel rule (wasmtime opens the +// module natively, so it needs no WASI mount); only wasmtime itself is located +// via the wrapper's runfiles. +func main() { + if len(os.Args) < 3 { + log.Fatal("Usage: loom_wrapper [args...]") + } + + loomWasm := os.Args[1] + loomArgs := os.Args[2:] + + // Initialize Bazel runfiles to locate wasmtime. + r, err := runfiles.New() + if err != nil { + log.Fatalf("Failed to initialize runfiles: %v", err) + } + + wasmtimeBinary, err := r.Rlocation("+wasmtime+wasmtime_toolchain/wasmtime") + if err != nil { + log.Fatalf("Failed to locate wasmtime: %v", err) + } + if _, err := os.Stat(wasmtimeBinary); err != nil { + log.Fatalf("Wasmtime binary not found at %s: %v", wasmtimeBinary, err) + } + + // Resolve file-path arguments to real paths and collect the directories + // that must be preopened for loom's WASI sandbox. + resolvedArgs, dirs := resolvePathsInArgs(loomArgs) + + // Build the wasmtime command: run [--dir ...] . + wasmtimeArgs := []string{"run"} + for _, dir := range uniqueStrings(dirs) { + wasmtimeArgs = append(wasmtimeArgs, "--dir", dir) + } + wasmtimeArgs = append(wasmtimeArgs, loomWasm) + wasmtimeArgs = append(wasmtimeArgs, resolvedArgs...) + + cmd := exec.Command(wasmtimeBinary, wasmtimeArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + log.Fatalf("Failed to execute wasmtime: %v", err) + } +} + +// resolvePathsInArgs resolves file-path arguments in a loom command to their +// real on-disk paths and returns the resolved argument list together with the +// directories that must be preopened (`--dir`) for wasmtime. +// +// Two argument shapes carry paths in loom's optimize command: +// - the value following a path flag (-o/--output, -i/--input), and +// - the positional input file (e.g. `optimize `). +// +// Flag values that are not paths (e.g. `--attestation false`, +// `--passes cse,inline`) and the subcommand token itself are left untouched: +// they are not existing files, so the positional-path heuristic skips them. +func resolvePathsInArgs(args []string) ([]string, []string) { + resolvedArgs := make([]string, 0, len(args)) + dirs := make([]string, 0) + + pathFlags := map[string]bool{ + "-o": true, "--output": true, + "-i": true, "--input": true, + } + + for i := 0; i < len(args); i++ { + arg := args[i] + + switch { + case pathFlags[arg] && i+1 < len(args): + // " " form. The output path does not exist yet, so + // resolve falls back to an absolute path; its parent directory + // (under bazel-out) does exist and is preopened. + resolvedArgs = append(resolvedArgs, arg) + i++ + real := resolvePath(args[i]) + resolvedArgs = append(resolvedArgs, real) + dirs = append(dirs, filepath.Dir(real)) + + case isExistingFile(arg): + // Positional input file (e.g. the component to optimize). + real := resolvePath(arg) + resolvedArgs = append(resolvedArgs, real) + dirs = append(dirs, filepath.Dir(real)) + + default: + // Subcommand, boolean/list flag values, or non-path tokens. + resolvedArgs = append(resolvedArgs, arg) + } + } + + return resolvedArgs, dirs +} + +// resolvePath returns the real path for p, following symlinks. If the symlink +// target cannot be evaluated (e.g. the path is an output that does not exist +// yet), it falls back to the absolute path. +func resolvePath(p string) string { + if real, err := filepath.EvalSymlinks(p); err == nil { + return real + } + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} + +// isExistingFile reports whether arg names an existing regular file. Used to +// distinguish a positional input path from non-path flag values. +func isExistingFile(arg string) bool { + info, err := os.Stat(arg) + if err != nil { + return false + } + return !info.IsDir() +} + +// uniqueStrings returns the unique strings from a slice, preserving order. +func uniqueStrings(strs []string) []string { + seen := make(map[string]bool, len(strs)) + result := make([]string, 0, len(strs)) + for _, s := range strs { + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + return result +} diff --git a/wasm/private/wasm_optimize.bzl b/wasm/private/wasm_optimize.bzl index 56df8354..c02894da 100644 --- a/wasm/private/wasm_optimize.bzl +++ b/wasm/private/wasm_optimize.bzl @@ -12,22 +12,23 @@ def _wasm_optimize_impl(ctx): input_wasm = ctx.file.component output_wasm = ctx.actions.declare_file(ctx.label.name + ".wasm") - # Get wasmtime toolchain for running loom.wasm - wasmtime_toolchain = ctx.toolchains["@rules_wasm_component//toolchains:wasmtime_toolchain_type"] - wasmtime = wasmtime_toolchain.wasmtime - - # Get LOOM WASM component + # Get LOOM WASM component and the wrapper that runs it under wasmtime. loom_wasm = ctx.file._loom_wasm - - # Build command arguments. + loom_wrapper = ctx.executable._loom_wrapper + + # Build command arguments for loom_wrapper, which prepends `wasmtime run` + # and the resolved `--dir` preopens (issue #490). + # + # loom reads input paths via WASI, and a fetched/adopted input is staged + # as a symlink whose target escapes a plain `--dir=.` preopen, so wasmtime + # refuses to follow it. The wrapper resolves the symlink on the host and + # preopens the real directory. It also keeps loom.wasm out of the WASI + # mount set (wasmtime opens the module natively), so loom.wasm is passed + # as the first argument. + # # Note: loom v0.3.0 does NOT accept the `--` separator between the wasm # module path and the subcommand when run under `wasmtime run`. - # TODO: loom reads input paths via WASI and `--dir=.` does not resolve - # the bazel-out symlinks; a small wrapper (mirroring wasmsign2_wrapper) - # is needed to compute real paths and register them as WASI mounts. args = ctx.actions.args() - args.add("run") - args.add("--dir=.") args.add(loom_wasm) args.add("optimize") args.add(input_wasm) @@ -54,7 +55,7 @@ def _wasm_optimize_impl(ctx): ctx.actions.run( inputs = [input_wasm, loom_wasm], outputs = [output_wasm], - executable = wasmtime, + executable = loom_wrapper, arguments = [args], mnemonic = "LoomOptimize", progress_message = "Optimizing WebAssembly component with LOOM: %{label}", @@ -120,8 +121,13 @@ advanced, branches, dce, merge-blocks, vacuum, simplify-locals""", default = "@loom_wasm//file:file", allow_single_file = True, ), + "_loom_wrapper": attr.label( + doc = "Wrapper that runs loom.wasm under wasmtime with symlink-resolved WASI mounts", + default = "//tools/loom_wrapper", + executable = True, + cfg = "exec", + ), }, - toolchains = ["@rules_wasm_component//toolchains:wasmtime_toolchain_type"], doc = """Optimize a WebAssembly component using LOOM. LOOM performs expression-level optimizations including: From f4eb4f8fb2e3a1cfd294e99da139f6bc46090772 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 13 Jun 2026 17:27:53 +0200 Subject: [PATCH 2/2] fix(loom_wrapper): pass wasmtime path from rule, not runfiles (#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the #490 preopen fix. avrabe verified the symlink fix works (zero "Input file not found"), but the wrapper then failed to locate wasmtime: Wasmtime binary not found at .../loom_wrapper.runfiles/+wasmtime+wasmtime_toolchain/wasmtime The hardcoded runfiles Rlocation `+wasmtime+wasmtime_toolchain/wasmtime` embeds the canonical repo name, which is correct only when rules_wasm_component is the root module. Consumed as a dependency (e.g. via git_override in jess), the canonical name gains a `rules_wasm_component+` prefix (`rules_wasm_component++wasmtime+wasmtime_toolchain`), so the lookup fails. Remove the runfiles lookup entirely: the rule now passes the wasmtime binary path as the first argument (and stages it as an action input), exactly as it already does for loom.wasm. wasmtime opens both natively, so neither needs a WASI mount, and there is no longer any canonical-repo-name dependency — the wrapper behaves identically whether rules_wasm_component is root or a dep. (The sibling wasmsign2_wrapper has the same latent Rlocation bug; tracked separately.) Verified: //tools/loom_wrapper compiles and //wasm/private:wasm_optimize analyses. Downstream falcon end-to-end re-test requested from the reporter. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/loom_wrapper/BUILD.bazel | 10 ++++----- tools/loom_wrapper/main.go | 38 ++++++++++++++-------------------- wasm/private/wasm_optimize.bzl | 19 ++++++++++++----- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/tools/loom_wrapper/BUILD.bazel b/tools/loom_wrapper/BUILD.bazel index 5fedd3df..4dfebccb 100644 --- a/tools/loom_wrapper/BUILD.bazel +++ b/tools/loom_wrapper/BUILD.bazel @@ -11,15 +11,13 @@ load("@rules_go//go:def.bzl", "go_binary") package(default_visibility = ["//visibility:public"]) # Wrapper binary that executes loom.wasm with symlink/path resolution. -# loom.wasm itself is supplied by the calling rule as the first argument -# (wasmtime opens the module natively); only wasmtime is located via runfiles. +# Both the wasmtime binary and loom.wasm are supplied by the calling rule as +# arguments (and staged as action inputs); wasmtime opens both natively. They +# are NOT resolved via runfiles, which would embed a canonical repo name that +# breaks when rules_wasm_component is consumed as a dependency (issue #490). go_binary( name = "loom_wrapper", srcs = ["main.go"], - data = [ - "@wasmtime_toolchain//:wasmtime", - ], pure = "on", # Pure Go for cross-platform compatibility visibility = ["//visibility:public"], - deps = ["@rules_go//go/runfiles"], ) diff --git a/tools/loom_wrapper/main.go b/tools/loom_wrapper/main.go index 9d4ea677..a11dd9fc 100644 --- a/tools/loom_wrapper/main.go +++ b/tools/loom_wrapper/main.go @@ -5,8 +5,6 @@ import ( "os" "os/exec" "path/filepath" - - "github.com/bazelbuild/rules_go/go/runfiles" ) // loom_wrapper runs the loom.wasm optimizer component under wasmtime. @@ -18,36 +16,30 @@ import ( // refuses to follow such a symlink under a plain `--dir=.` preopen, so loom // reports "Input file not found" (issue #490). Resolving the symlink on the // host and preopening the resolved directory gives loom a real directory to -// read from. This mirrors tools/wasmsign2_wrapper, which solves the identical -// problem for the wasmsign2 component. +// read from. // // Usage: // -// loom_wrapper [args...] +// loom_wrapper [args...] // -// e.g. loom_wrapper bazel-out/.../loom.wasm optimize -o [flags] +// e.g. loom_wrapper .../wasmtime .../loom.wasm optimize -o [flags] // -// The loom.wasm module path is passed by the Bazel rule (wasmtime opens the -// module natively, so it needs no WASI mount); only wasmtime itself is located -// via the wrapper's runfiles. +// Both the wasmtime binary and the loom.wasm module path are passed by the +// Bazel rule (and staged as action inputs). They are deliberately NOT located +// via the wrapper's runfiles: a hardcoded runfiles Rlocation embeds the +// canonical repo name, which differs when rules_wasm_component is the root +// module vs a dependency (the latter gains a `rules_wasm_component+` prefix), +// breaking downstream consumers (issue #490 follow-up). wasmtime opens both +// files natively, so neither needs a WASI mount. func main() { - if len(os.Args) < 3 { - log.Fatal("Usage: loom_wrapper [args...]") + if len(os.Args) < 4 { + log.Fatal("Usage: loom_wrapper [args...]") } - loomWasm := os.Args[1] - loomArgs := os.Args[2:] - - // Initialize Bazel runfiles to locate wasmtime. - r, err := runfiles.New() - if err != nil { - log.Fatalf("Failed to initialize runfiles: %v", err) - } + wasmtimeBinary := os.Args[1] + loomWasm := os.Args[2] + loomArgs := os.Args[3:] - wasmtimeBinary, err := r.Rlocation("+wasmtime+wasmtime_toolchain/wasmtime") - if err != nil { - log.Fatalf("Failed to locate wasmtime: %v", err) - } if _, err := os.Stat(wasmtimeBinary); err != nil { log.Fatalf("Wasmtime binary not found at %s: %v", wasmtimeBinary, err) } diff --git a/wasm/private/wasm_optimize.bzl b/wasm/private/wasm_optimize.bzl index c02894da..4ef32e60 100644 --- a/wasm/private/wasm_optimize.bzl +++ b/wasm/private/wasm_optimize.bzl @@ -12,9 +12,10 @@ def _wasm_optimize_impl(ctx): input_wasm = ctx.file.component output_wasm = ctx.actions.declare_file(ctx.label.name + ".wasm") - # Get LOOM WASM component and the wrapper that runs it under wasmtime. + # Get LOOM WASM component, the wrapper that runs it, and wasmtime. loom_wasm = ctx.file._loom_wasm loom_wrapper = ctx.executable._loom_wrapper + wasmtime = ctx.toolchains["@rules_wasm_component//toolchains:wasmtime_toolchain_type"].wasmtime # Build command arguments for loom_wrapper, which prepends `wasmtime run` # and the resolved `--dir` preopens (issue #490). @@ -22,13 +23,20 @@ def _wasm_optimize_impl(ctx): # loom reads input paths via WASI, and a fetched/adopted input is staged # as a symlink whose target escapes a plain `--dir=.` preopen, so wasmtime # refuses to follow it. The wrapper resolves the symlink on the host and - # preopens the real directory. It also keeps loom.wasm out of the WASI - # mount set (wasmtime opens the module natively), so loom.wasm is passed - # as the first argument. + # preopens the real directory. + # + # The wasmtime binary and loom.wasm are passed as the first two arguments + # rather than located via the wrapper's runfiles: a hardcoded runfiles + # Rlocation embeds the canonical repo name, which differs when + # rules_wasm_component is the root module vs a dependency (the latter gains + # a `rules_wasm_component+` prefix), so the lookup fails for downstream + # consumers. wasmtime opens both files natively, so neither needs a WASI + # mount. # # Note: loom v0.3.0 does NOT accept the `--` separator between the wasm # module path and the subcommand when run under `wasmtime run`. args = ctx.actions.args() + args.add(wasmtime) args.add(loom_wasm) args.add("optimize") args.add(input_wasm) @@ -53,7 +61,7 @@ def _wasm_optimize_impl(ctx): args.add("--passes", ",".join(ctx.attr.passes)) ctx.actions.run( - inputs = [input_wasm, loom_wasm], + inputs = [input_wasm, loom_wasm, wasmtime], outputs = [output_wasm], executable = loom_wrapper, arguments = [args], @@ -128,6 +136,7 @@ advanced, branches, dce, merge-blocks, vacuum, simplify-locals""", cfg = "exec", ), }, + toolchains = ["@rules_wasm_component//toolchains:wasmtime_toolchain_type"], doc = """Optimize a WebAssembly component using LOOM. LOOM performs expression-level optimizations including: