diff --git a/tools/loom_wrapper/BUILD.bazel b/tools/loom_wrapper/BUILD.bazel new file mode 100644 index 00000000..4dfebccb --- /dev/null +++ b/tools/loom_wrapper/BUILD.bazel @@ -0,0 +1,23 @@ +"""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. +# 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"], + pure = "on", # Pure Go for cross-platform compatibility + visibility = ["//visibility:public"], +) diff --git a/tools/loom_wrapper/main.go b/tools/loom_wrapper/main.go new file mode 100644 index 00000000..a11dd9fc --- /dev/null +++ b/tools/loom_wrapper/main.go @@ -0,0 +1,155 @@ +package main + +import ( + "log" + "os" + "os/exec" + "path/filepath" +) + +// 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. +// +// Usage: +// +// loom_wrapper [args...] +// +// e.g. loom_wrapper .../wasmtime .../loom.wasm optimize -o [flags] +// +// 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) < 4 { + log.Fatal("Usage: loom_wrapper [args...]") + } + + wasmtimeBinary := os.Args[1] + loomWasm := os.Args[2] + loomArgs := os.Args[3:] + + 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..4ef32e60 100644 --- a/wasm/private/wasm_optimize.bzl +++ b/wasm/private/wasm_optimize.bzl @@ -12,22 +12,31 @@ 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, the wrapper that runs it, and wasmtime. loom_wasm = ctx.file._loom_wasm - - # Build command arguments. + 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). + # + # 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. + # + # 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`. - # 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(wasmtime) args.add(loom_wasm) args.add("optimize") args.add(input_wasm) @@ -52,9 +61,9 @@ 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 = wasmtime, + executable = loom_wrapper, arguments = [args], mnemonic = "LoomOptimize", progress_message = "Optimizing WebAssembly component with LOOM: %{label}", @@ -120,6 +129,12 @@ 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.