diff --git a/.github/workflows/ci-workflows.yaml b/.github/workflows/ci-workflows.yaml index 17ba61720a..d0a389cf12 100644 --- a/.github/workflows/ci-workflows.yaml +++ b/.github/workflows/ci-workflows.yaml @@ -272,7 +272,6 @@ jobs: # https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#included-storage-and-minutes smoke: name: smoke (${{ matrix.workspace.path }}, ${{ matrix.os }}) - if: github.ref_name == 'main' || contains(github.head_ref, 'macos') || contains(github.head_ref, 'windows') runs-on: ${{ matrix.os }} strategy: fail-fast: false diff --git a/MODULE.bazel b/MODULE.bazel index ba2c6aafa7..7d13a091fc 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -16,6 +16,7 @@ bazel_dep(name = "bazel_features", version = "1.41.0") bazel_dep(name = "bazel_skylib", version = "1.5.0") bazel_dep(name = "platforms", version = "1.0.0") bazel_dep(name = "rules_nodejs", version = "6.7.3") +bazel_dep(name = "hermetic_launcher", version = "0.0.15") # Changes ensured by rules_js: # 3.2.2: https://github.com/bazel-contrib/bazel-lib/commit/cac2d7855949d1b222fa26888892fbbe1d31015d diff --git a/docs/hermetic_launcher.md b/docs/hermetic_launcher.md new file mode 100644 index 0000000000..8dc27acf28 --- /dev/null +++ b/docs/hermetic_launcher.md @@ -0,0 +1,151 @@ +# The hermetic launcher + +A `js_binary` is normally invoked through a generated bash script, which works +out where node, the fs patches and the entry point are, exports a set of +`JS_BINARY__*` variables, changes into the root of the output tree, and finally +execs node. That is a shell process and a few hundred lines of path resolution +on every invocation, which is noticeable when the `js_binary` is the tool of a +build action that runs thousands of times. + +The hermetic launcher is an alternative: a small native binary, stamped per +target by [hermetic_launcher](https://github.com/hermeticbuild/hermetic-launcher), +which does nothing but `execve` node with a fixed set of arguments baked into it. +No shell is involved. + +`js_run_binary` uses it automatically for a target when it can determine that +doing so behaves identically. Nothing else does yet; `bazel run` and `bazel test` +still go through the bash launcher. + +## What replaces the launcher script + +The launcher binary can only `execve`. Everything the script did before reaching +node is instead done by `js/private/node-bootstrap/launcher.cjs`, which node +loads with `--require` before the entry point. Running inside node, before any +user code, it can do almost everything the shell did: + +- consume the `--bazel-bindir` flag that `js_run_binary` passes, so that it does + not reach the program as a positional argument +- change into the bin directory, preserving the "everything runs from the root + of the output tree" contract +- derive the execroot and the runfiles root, and set `JS_BINARY__FS_PATCH_ROOTS` + so that the fs patches apply +- put the node wrapper on the `PATH` and set `JS_BINARY__NODE_BINARY`, + `JS_BINARY__NODE_WRAPPER` and `JS_BINARY__NODE_PATCHES`, so that a child + process which shells out to `node` still gets the patched runtime +- honour `JS_BINARY__CHDIR` + +Two kinds of thing are out of reach. Node CLI flags have to be baked into the +launcher, because node has already parsed its options by the time a preload +runs; only `--preserve-symlinks-main` is. And the per-target constants the +script bakes in -- `JS_BINARY__WORKSPACE`, `JS_BINARY__TARGET`, +`JS_BINARY__PACKAGE`, `JS_BINARY__BUILD_FILE_PATH`, +`JS_BINARY__COMPILATION_MODE`, `JS_BINARY__TARGET_CPU` and `JS_BINARY__BINDIR` +-- are not set at all, since there is no channel to carry them. A program that +reads one of those will see `undefined`. + +## When it is used + +Both the `js_binary` and the `js_run_binary` have to qualify. + +A `js_binary` is disqualified by `chdir`, `env`, `node_options`, +`expected_exit_code` or `include_npm`, by targeting Windows, by being a +coverage-enabled test, or by setting `copy_data_to_bin = False` -- without that the entry +point is never copied to the bindir, and the execroot mode below has nothing to run. +`patch_node_fs` is not a disqualifier: `js_run_binary` always passes it through the +action environment. + +`fixed_args` are disqualifying only when they need a shell. The launcher can carry +an argument verbatim, and it can resolve one through its runfiles, so the documented +`fixed_args = ["--config", "$$RUNFILES_DIR/$(rlocationpath :config)"]` idiom is +supported: `$(rlocationpath ...)` is expanded at analysis time, and the launcher +resolves what remains to the same absolute path the shell would have produced from +`$RUNFILES_DIR`. A `fixed_arg` containing any other `$`, or spelled +`--bazel-bindir`, is a disqualifier. Since `fixed_args` become embedded arguments +they also consume the launcher's ten argument slots, five of which are already +taken; a target with too many falls back to the bash launcher. + +A `js_run_binary` is disqualified by a `--node_options=` entry in `args`. Its `env` is not +consulted at all. + +`stdout`, `stderr`, `exit_code_out` and `silent_on_success` are not disqualifiers, even +though all four need work after the program exits and the launcher only `execve`s. +`js_run_binary` forwards them to `run_binary`, which captures through its `spawn_binary` +wrapper -- a process that outlives the program and can do that work. This costs nothing: +the launcher script only forks rather than `exec`s because of these same features, so the +wrapper's fork replaces the script's. + +Setting the matching `JS_BINARY__*` variable through `env` by hand is the one way to ask +for something the launcher genuinely cannot do, since only the script implements those. +It is not treated as a disqualifier: it means going out of the way to reach for a private +variable in place of the attribute that exists for it, and `launcher.cjs` refuses to run +at all when it sees one, naming the variable. The variables that do have an +implementation here -- `JS_BINARY__CHDIR`, `JS_BINARY__NO_CD_BINDIR`, +`JS_BINARY__LOG_*`, `JS_BINARY__USE_EXECROOT_ENTRY_POINT` -- are honoured either way. + +`log_level` is not a disqualifier on either side, because it only selects how much +diagnostic output is printed. `js_run_binary` passes `JS_BINARY__LOG_*` through the +action environment, so a level set there reaches `launcher.cjs` and `bootstrap.cjs` +unchanged. What differs is the detail: the launcher script's info and debug output +dumps the `PATH`, the `BAZEL_*` and `JS_BINARY__*` values it computed and the node +command line, and none of that is printed when the script does not run. A `log_level` +set on the `js_binary` itself has no channel to the stub and is not applied at all. + +`use_execroot_entry_point` is not a disqualifier either, though it is the one place where +the launcher has to do real work the stub cannot express. The two modes differ in which +copy of the entry point node runs, and therefore in the directory node walks up from to +resolve the program's own `require`s: + +| mode | main module | resolution root | +| --- | --- | --- | +| `False` | `$RUNFILES//` | the tool's runfiles tree | +| `True` | `$EXECROOT/$BAZEL_BINDIR/` | the target-configuration bin tree, where the action's `srcs` and outputs also live | + +Only the runfiles form can be baked into the launcher binary: its single argument +transformation resolves against the runfiles root, and the bindir is the *target* +configuration's, which a `js_binary` analyzed for the exec platform does not know. So +`launcher.cjs` composes the execroot form at startup instead, exactly as the launcher +script does, and then redirects node's main module to it. It needs no extra embedded +argument to do so: an rlocation path and a short path differ only in their first segment, +so the short path is recovered from the runfiles path already in `argv[1]`. That inversion +needs the name of the main repository, which under bzlmod -- the only mode rules_js +supports -- is always `_main`. + +Redirecting the main is what moves the resolution root, so it has to happen before node +loads it. For a CommonJS main that is a `Module._resolveFilename` hook; for an ES module it +is a `module.registerHooks` resolve hook, and on a node too old to have that +(before 22.15) the launcher re-executes node on the right file rather than run the wrong +copy. Both hooks are installed, so nothing has to predict which loader node will choose. +Only the process the build action launched does any of this -- a child that re-enters node +was handed its own script, and the launcher script would not have touched that either. + +See [use_execroot_entry_point.md](use_execroot_entry_point.md) for what the two modes are +for. + +Anything that does not qualify keeps using the bash launcher, with no error. + +## Finding out what a target got + +The action's executable path differs, so `aquery` answers the question directly +and cannot be out of date: + +```sh +bazel aquery --output=textproto //your:target | grep -A2 'JsRunBinary' +``` + +For the `js_binary` side, every target publishes its verdict in an output group, +which is written only when asked for: + +```sh +bazel build //... --output_groups=hermetic_launcher_report +find -L bazel-out -name hermetic_launcher_report.txt | xargs cat +``` + +Each line is the target label followed by `eligible`, `unavailable` (no prebuilt +stub for this platform, or an embedded argument over the launcher's 256-byte +limit), or `blocked:` and a comma-separated list of reason codes. Summing the +last column over a whole repository shows what is holding adoption back: + +```sh +find -L bazel-out -name hermetic_launcher_report.txt | xargs cat | + sed 's/^[^ ]* //' | sort | uniq -c | sort -rn +``` diff --git a/e2e/path_mapping/js_run_binary_path_mapping_check/BUILD.bazel b/e2e/path_mapping/js_run_binary_path_mapping_check/BUILD.bazel index 58fb1fa3e4..24fafc34e9 100644 --- a/e2e/path_mapping/js_run_binary_path_mapping_check/BUILD.bazel +++ b/e2e/path_mapping/js_run_binary_path_mapping_check/BUILD.bazel @@ -67,3 +67,25 @@ js_run_binary( tags = ["manual"], tool = ":check", ) + +# Same again, but configured so the js_run_binary gate selects the hermetic launcher: +# silent_on_success off, and the entry point resolved through runfiles. check.mjs's two +# assertions -- that BAZEL_BINDIR is the path-mapped bindir, and that --bazel-bindir did +# not leak into argv -- are exactly what launcher.cjs has to get right in place of the +# launcher script, and `chdir` exercises the one JS_BINARY__* variable it honours. +js_run_binary( + name = "js_run_binary_path_mapping_check_hermetic", + outs = ["out_hermetic.txt"], + args = ["out_hermetic.txt"], + chdir = package_name(), + mnemonic = "JsRunBinaryPathMappingCheckHermetic", + set_legacy_environment_variables = False, + silent_on_success = False, + tool = ":check", + use_execroot_entry_point = False, +) + +build_test( + name = "js_run_binary_path_mapping_check_hermetic_test", + targets = [":js_run_binary_path_mapping_check_hermetic"], +) diff --git a/e2e/path_mapping/test.sh b/e2e/path_mapping/test.sh index 06ea4063a9..0f1833839a 100755 --- a/e2e/path_mapping/test.sh +++ b/e2e/path_mapping/test.sh @@ -107,3 +107,33 @@ if [ "$cache_hit3" != "true" ]; then fi echo "PASS: js_run_binary action controlled by --@aspect_rules_js//js:set_legacy_environment_variables=False was cache-shared across -c fastbuild and -c opt" + +# Same as above for js_run_binary_path_mapping_check_hermetic, which is configured so +# that js_run_binary selects the hermetic launcher. There the --bazel-bindir flag is +# consumed by the launcher.cjs preload rather than by the launcher script, so this is +# what proves that path is path-mapping-safe too. +exec_log4="$scratch/exec_log4.json" + +bazel build -c fastbuild //js_run_binary_path_mapping_check:js_run_binary_path_mapping_check_hermetic \ + --disk_cache="$disk_cache" \ + --action_env="JS_RUN_BINARY_PATH_MAPPING_CHECK_HERMETIC_INVALIDATE=$invalidate" + +bazel build -c opt //js_run_binary_path_mapping_check:js_run_binary_path_mapping_check_hermetic \ + --disk_cache="$disk_cache" \ + --action_env="JS_RUN_BINARY_PATH_MAPPING_CHECK_HERMETIC_INVALIDATE=$invalidate" \ + --execution_log_json_file="$exec_log4" + +matches4="$(jq -s '[.[] | select(.mnemonic == "JsRunBinaryPathMappingCheckHermetic")]' "$exec_log4")" +count4="$(echo "$matches4" | jq 'length')" +if [ "$count4" -eq 0 ]; then + echo "FAIL: no JsRunBinaryPathMappingCheckHermetic entry found in the -c opt execution log" >&2 + exit 1 +fi + +cache_hit4="$(echo "$matches4" | jq -r '.[0].cacheHit')" +if [ "$cache_hit4" != "true" ]; then + echo "FAIL: hermetic launcher js_run_binary action was re-executed under -c opt (cacheHit=$cache_hit4); path mapping did not share the cache entry from -c fastbuild" >&2 + exit 1 +fi + +echo "PASS: js_run_binary action using the hermetic launcher was cache-shared across -c fastbuild and -c opt" diff --git a/js/private/BUILD.bazel b/js/private/BUILD.bazel index 42ed04c3d7..c74b548c51 100644 --- a/js/private/BUILD.bazel +++ b/js/private/BUILD.bazel @@ -50,9 +50,15 @@ bzl_library( "@bazel_lib//lib:windows_utils", "@bazel_skylib//lib:dicts", "@bazel_tools//tools/build_defs/repo:cache.bzl", + "@hermetic_launcher//launcher:lib_bzl", ], ) +bzl_library( + name = "hermetic_tool", + srcs = ["hermetic_tool.bzl"], +) + bzl_library( name = "js_helpers", srcs = ["js_helpers.bzl"], @@ -80,6 +86,7 @@ bzl_library( name = "js_run_binary", srcs = ["js_run_binary.bzl"], deps = [ + ":hermetic_tool", ":js_helpers", ":js_info_files", ":js_library", diff --git a/js/private/coverage/bundle/BUILD.bazel b/js/private/coverage/bundle/BUILD.bazel index 339c79b354..2bf22a46bc 100644 --- a/js/private/coverage/bundle/BUILD.bazel +++ b/js/private/coverage/bundle/BUILD.bazel @@ -22,16 +22,21 @@ rollup_bin.rollup( ":node_modules/c8", ], outs = ["bundle.js"], + # Only the config path needs `$RUNFILES_DIR` expanded; the rest are ordinary `args`, + # which follow `fixed_args`, so rollup sees the same command line either way and the + # hermetic launcher's embedded argument budget is not spent on them. + args = [ + "--format", + "cjs", + "--file", + "bundle.js", + ], chdir = package_name(), data = [":config"], fixed_args = [ "c8.js", "--config", "$$RUNFILES_DIR/$(rlocationpath :config)", - "--format", - "cjs", - "--file", - "bundle.js", ], silent_on_success = False, visibility = ["//js/private/coverage:__pkg__"], diff --git a/js/private/devserver/src/BUILD.bazel b/js/private/devserver/src/BUILD.bazel index 9f836bff64..b558744b2b 100644 --- a/js/private/devserver/src/BUILD.bazel +++ b/js/private/devserver/src/BUILD.bazel @@ -17,16 +17,21 @@ rollup_bin.rollup( "//js/private/watch", ], outs = ["bundle.mjs"], + # Only the config path needs `$RUNFILES_DIR` expanded; the rest are ordinary `args`, + # which follow `fixed_args`, so rollup sees the same command line either way and the + # hermetic launcher's embedded argument budget is not spent on them. + args = [ + "--format", + "es", + "--file", + "bundle.mjs", + ], chdir = package_name(), data = [":config"], fixed_args = [ "js_run_devserver.mjs", "--config", "$$RUNFILES_DIR/$(rlocationpath :config)", - "--format", - "es", - "--file", - "bundle.mjs", ], silent_on_success = False, visibility = ["//js/private/devserver:__pkg__"], diff --git a/js/private/hermetic_tool.bzl b/js/private/hermetic_tool.bzl new file mode 100644 index 0000000000..c451ff180b --- /dev/null +++ b/js/private/hermetic_tool.bzl @@ -0,0 +1,73 @@ +"""A js_binary tool that runs through the hermetic launcher where that is possible. + +`js_run_binary` runs its tool through bazel-lib's `run_binary`, which uses the tool +target's `DefaultInfo.executable` -- the bash launcher script. Swapping in the native +launcher therefore means handing `run_binary` a different tool target, which is what +this rule is. + +It also solves where the launcher finds its runfiles. The launcher resolves its +embedded rlocation paths against `$RUNFILES_DIR` or a `.runfiles` tree +adjacent to itself, and `RUNFILES_DIR` cannot be set in the action environment because +environment values are never path-mapped. Bazel materializes a runfiles tree next to +whatever a target declares as its executable, so a symlink owned by this rule gets one. +""" + +def _hermetic_tool_impl(ctx): + binary = ctx.attr.binary + default_info = binary[DefaultInfo] + + launchers = [] + if OutputGroupInfo in binary and hasattr(binary[OutputGroupInfo], "hermetic_launcher"): + launchers = binary[OutputGroupInfo].hermetic_launcher.to_list() + + extra_runfiles = [] + if launchers: + target_file = launchers[0] + + # Not part of the js_binary's own runfiles, so that a target which never uses a + # hermetic launcher does not carry it. Add it here, where it is needed. + extra_runfiles.append(ctx.file._hermetic_bootstrap) + else: + # No launcher for this target: it has a blocker, or hermetic_launcher publishes + # no stub for this platform. Fall back to the bash launcher, which is what the + # tool would have been used as anyway. + target_file = default_info.files_to_run.executable + + # Windows dispatches on the file extension, so the symlink has to keep the one the + # file it points at has: the js_binary's executable there is a .bat wrapper, and a + # copy of it named without the extension is not executable at all (CreateProcessW + # fails with error 193, "not a valid Win32 application"). + name = ctx.label.name + if target_file.extension: + name += "." + target_file.extension + executable = ctx.actions.declare_file(name) + ctx.actions.symlink( + output = executable, + target_file = target_file, + is_executable = True, + ) + + return [DefaultInfo( + files = depset([executable]), + executable = executable, + runfiles = default_info.default_runfiles.merge( + ctx.runfiles(files = extra_runfiles), + ), + )] + +hermetic_tool = rule( + doc = "Wraps a `js_binary` so that it runs through its hermetic launcher when it has one.", + implementation = _hermetic_tool_impl, + attrs = { + "binary": attr.label( + doc = "The `js_binary` to wrap.", + mandatory = True, + providers = [DefaultInfo], + ), + "_hermetic_bootstrap": attr.label( + allow_single_file = True, + default = Label("@aspect_rules_js//js/private/node-bootstrap:launcher.cjs"), + ), + }, + executable = True, +) diff --git a/js/private/js_binary.bzl b/js/private/js_binary.bzl index e25c01f296..e088cb8808 100644 --- a/js/private/js_binary.bzl +++ b/js/private/js_binary.bzl @@ -3,7 +3,9 @@ load("@bazel_lib//lib:copy_to_bin.bzl", "COPY_FILE_TO_BIN_TOOLCHAINS") load("@bazel_lib//lib:directory_path.bzl", "DirectoryPathInfo") load("@bazel_lib//lib:expand_make_vars.bzl", "expand_locations", "expand_variables") +load("@bazel_lib//lib:paths.bzl", "to_rlocation_path") load("@bazel_lib//lib:windows_utils.bzl", "create_windows_native_launcher_script") +load("@hermetic_launcher//launcher:lib.bzl", "launcher") load(":bash.bzl", "BASH_INITIALIZE_RUNFILES") load(":js_helpers.bzl", "LOG_LEVELS", "envs_for_log_level", "gather_files_from_js_infos", "gather_runfiles") @@ -277,6 +279,10 @@ _ATTRS = { allow_single_file = True, default = Label("@aspect_rules_js//js/private/node-bootstrap:bootstrap.cjs"), ), + "_hermetic_bootstrap": attr.label( + allow_single_file = True, + default = Label("@aspect_rules_js//js/private/node-bootstrap:launcher.cjs"), + ), } _ENV_SET = """export {var}={quoted_value}""" @@ -297,6 +303,286 @@ def _generates_coverage_report(ctx): ctx.attr.testonly and ctx.configuration.coverage_enabled) +# Resolved here as Labels rather than used as the bare strings hermetic_launcher +# exposes: under --incompatible_auto_exec_groups a string toolchain type is resolved +# against the repository mapping of whichever module is being built, so a consumer +# that does not itself depend on hermetic_launcher cannot resolve the name. A Label +# is resolved against this file's own mapping at load time instead. +_FINALIZER_TOOLCHAIN_TYPE = Label(launcher.finalizer_toolchain_type) +_TEMPLATE_TOOLCHAIN_TYPE = Label(launcher.template_toolchain_type) + +# Limits of the prebuilt stub hermetic_launcher patches: arg0..arg9, 256 bytes each. +_MAX_EMBEDDED_ARGS = 10 +_MAX_EMBEDDED_ARG_LENGTH = 256 + +def _compile_stub(ctx, embedded_args, transformed_args, output_file): + """Stamps a launcher binary from the prebuilt template stub. + + This is `launcher.compile_stub` reimplemented so the finalizer toolchain can be + named by Label; see the comment on _FINALIZER_TOOLCHAIN_TYPE. Drop this in favour + of the upstream helper once it takes Labels. + """ + template = ctx.toolchains[_TEMPLATE_TOOLCHAIN_TYPE].templatetoolchaininfo.template_exe + args = ctx.actions.args() + args.add("--template", template) + args.add("-o", output_file) + args.add_joined("--transform", transformed_args, join_with = ",") + args.add("--") + args.add_all(embedded_args) + ctx.actions.run( + outputs = [output_file], + executable = ctx.toolchains[_FINALIZER_TOOLCHAIN_TYPE].finalizer_info.finalizer, + arguments = [args], + inputs = [template], + toolchain = _FINALIZER_TOOLCHAIN_TYPE, + mnemonic = "JsLauncher", + progress_message = "Stamping launcher %{output}", + ) + +# The two spellings of the runfiles-root reference that the bash launcher expands in +# `fixed_args`, and that the stub can resolve instead. See _classify_fixed_args. +_RUNFILES_DIR_PREFIXES = ["$RUNFILES_DIR/", "${RUNFILES_DIR}/"] + +def _classify_fixed_args(fixed_args): + """Splits expanded `fixed_args` into stub arguments, or reports that it cannot. + + The bash launcher inlines `fixed_args` into the script, so the shell expands them. + The stub has no shell, but it can resolve an argument through runfiles, which is + exactly what the documented `"$$RUNFILES_DIR/$(rlocationpath :config)"` idiom needs: + `$(rlocationpath ...)` is already expanded by the time we get here, so what is left + is a runfiles-root reference followed by an rlocation path. + + Returns a `(specs, blockers)` tuple. Each spec is a `(kind, value)` pair, where kind + is "embedded" for a verbatim argument or "runfile" for one the launcher resolves + against its runfiles at startup. Anything else needs a shell and is a blocker. + """ + specs = [] + for arg in fixed_args: + prefix = None + for candidate in _RUNFILES_DIR_PREFIXES: + if arg.startswith(candidate): + prefix = candidate + if prefix: + rlocation = arg[len(prefix):] + + # Only a bare rlocation path can be resolved; anything else in there is + # more shell than the stub can do. + if "$" in rlocation or not rlocation: + return [], ["JS_BINARY_FIXED_ARGS"] + specs.append(("runfile", rlocation)) + continue + + # Any other `$` is a shell expansion, a make variable that expand_args was not + # asked to substitute, or a runfiles reference in a form not handled above. + if "$" in arg: + return [], ["JS_BINARY_FIXED_ARGS"] + + # js_run_binary passes `--bazel-bindir ` after the embedded arguments, and + # launcher.cjs consumes the first occurrence. A fixed arg spelled the same way + # would be consumed instead. + if arg == "--bazel-bindir": + return [], ["JS_BINARY_FIXED_ARGS"] + specs.append(("embedded", arg)) + + return specs, [] + +def _hermetic_launcher_blockers(ctx, fixed_arg_blockers, fixed_env, is_windows): + """Why this target cannot use the hermetic launcher, as a list of reason codes. + + An allowlist rather than a denylist: everything the bash launcher does has to be + either reproduced by launcher.cjs or named here, so an attribute added to + js_binary later makes targets ineligible rather than silently losing behaviour. + """ + blockers = [] + + # Baked into the launcher script, so the stub can never see them. `env` and + # `chdir` are also delivered by js_run_binary as action env, which launcher.cjs + # does honour; it is only the js_binary-level values that have no channel. + if ctx.attr.chdir: + blockers.append("JS_BINARY_CHDIR") + if ctx.attr.env or fixed_env: + blockers.append("JS_BINARY_ENV") + + # `fixed_args` are baked into the launcher script too, but the stub can carry them + # as embedded arguments, so only the ones needing a shell are a blocker. + blockers.extend(fixed_arg_blockers) + + # node parses its options before any preload runs, so these can only be embedded + # arguments; only --preserve-symlinks-main is, so far. + if ctx.attr.node_options: + blockers.append("JS_BINARY_NODE_OPTIONS") + + # Needs work after the program exits, and the stub execve's. + if ctx.attr.expected_exit_code: + blockers.append("JS_BINARY_EXPECTED_EXIT_CODE") + + # Needs JS_BINARY__NPM_BINARY and the npm wrapper directory on the PATH. + if ctx.attr.include_npm: + blockers.append("JS_BINARY_INCLUDE_NPM") + + # The launcher script refuses to run the execroot entry point without this, since + # nothing would have put the entry point in the bindir (js_binary.sh.tpl). The check + # cannot be ported: it reads JS_BINARY__COPY_DATA_TO_BIN, a per-target constant the + # stub has no way to carry, and js_run_binary's + # allow_execroot_entry_point_with_no_copy_data_to_bin escape hatch is not visible from + # here either. Defaults to True, so this blocks almost nothing. + if not ctx.attr.copy_data_to_bin: + blockers.append("JS_BINARY_COPY_DATA_TO_BIN") + + # `patch_node_fs` is deliberately not a blocker: js_run_binary always passes + # JS_BINARY__PATCH_NODE_FS through the action environment, and the launcher script + # only sets it if it is not already set, so the caller's value wins either way. + + # `log_level` is deliberately not a blocker. The launcher script's info and debug + # output is diagnostic only -- it dumps PATH, the BAZEL_* and JS_BINARY__* values it + # computed, and the node command line -- so losing it changes what is printed and + # nothing else. launcher.cjs logs the steps it performs, and js_run_binary passes + # JS_BINARY__LOG_* through the action environment, so a log level set there still + # reaches the preload and bootstrap.cjs. Only a level set on the js_binary itself has + # no channel to the stub, and is silently not applied. + + # The fs patches are force-disabled on Windows (#1137), and the stub there spawns + # and waits rather than execve'ing. + # + # This subsumes `enable_runfiles`, which switches every path to execroot + # resolution: the launcher script only consults it on Windows, and its + # config_setting matches only when --enable_runfiles is passed explicitly, so it + # reads False on a normal Linux build where runfiles are in fact enabled. + if is_windows: + blockers.append("WINDOWS") + + # NODE_V8_COVERAGE has to be set before node starts, and the report generator has + # to run after it exits. + if _generates_coverage_report(ctx): + blockers.append("COVERAGE") + + return blockers + +def _hermetic_launcher(ctx, nodeinfo, entry_point_rlocation, fixed_arg_specs, blockers, is_windows): + """Stamps a launcher binary that runs the entry point on node with the fs patches applied. + + This is an alternative to the bash launcher: everything it needs is baked into the + binary, so running it with no arguments runs the js_binary's program with no shell + involved. It is independent of the bash launcher, which is still what `bazel run` + executes, and is exposed only through the `hermetic_launcher` output group so that + the stamping action does not run unless something asks for it. + + The stub can only execve, so the environment setup the bash launcher does in shell + is done by the launcher.cjs preload instead, in node, before the entry point loads. + What neither of them can do is named by _hermetic_launcher_blockers; a target with + any blocker gets no launcher at all and its consumers keep using the bash one. + + Returns None when there is a blocker, or when hermetic_launcher publishes no stub + for the target platform (e.g. linux ppc64le, windows arm64). + """ + if not ctx.toolchains[_TEMPLATE_TOOLCHAIN_TYPE] or not ctx.toolchains[_FINALIZER_TOOLCHAIN_TYPE]: + return None + if blockers: + return None + + # Each entry is either a File, resolved through runfiles when the launcher runs, or + # a string, embedded verbatim. + # + # There is no `--` before the entry point. It would only be needed if the entry + # point could look like a node option, and it cannot: the launcher resolves a + # transformed argument by prefixing the runfiles root, which is absolute, so what + # node sees always begins with a path separator. Spending a tenth of the argument + # budget to guard an impossible case is worse than not having the guard. + # + # The preload is launcher.cjs rather than the fs patches directly: it reconstructs + # what the bash launcher's environment setup would have done and then requires the + # patches itself. --preserve-symlinks-main has to be here because it is a node CLI + # flag, so no preload can apply it. + # + # `--require` and its value stay two arguments. The launcher cannot resolve + # `--require=` as one, because the transform prepends the runfiles root to the + # whole argument rather than to a path inside it: the result is + # `/--require=`, and node reports the module as missing. + args = [] + if ctx.attr.preserve_symlinks_main: + args.append("--preserve-symlinks-main") + args.extend(["--require", ctx.file._hermetic_bootstrap]) + + if nodeinfo.node: + embedded_args, transformed_args = launcher.args_from_entrypoint(nodeinfo.node) + elif nodeinfo.node_path.startswith("/"): + # A node_toolchain may name a non-hermetic node by absolute path rather than + # provide a File. The launcher passes absolute paths through untouched, so + # there is nothing for it to resolve. + embedded_args, transformed_args = [nodeinfo.node_path], [] + else: + # A relative node_path is relative to this workspace within the runfiles tree, + # matching how the launcher script resolves it. + embedded_args, transformed_args = ["{}/{}".format(ctx.workspace_name, nodeinfo.node_path)], [0] + + for arg in args: + if type(arg) == "File": + embedded_args, transformed_args = launcher.append_runfile( + file = arg, + embedded_args = embedded_args, + transformed_args = transformed_args, + ) + else: + embedded_args, transformed_args = launcher.append_embedded_arg( + arg = arg, + embedded_args = embedded_args, + transformed_args = transformed_args, + ) + + # A runfiles path we already hold as a string rather than a File, since the entry + # point may be a file inside a directory artifact. + embedded_args, transformed_args = launcher.append_raw_transformed_arg( + arg = entry_point_rlocation, + embedded_args = embedded_args, + transformed_args = transformed_args, + ) + + # After the entry point and before the arguments the caller passes at run time, + # which is where the launcher script puts them. A "runfile" spec is resolved by the + # launcher at startup, the same absolute path the script gets from expanding + # `$RUNFILES_DIR`; see _classify_fixed_args. + for (kind, value) in fixed_arg_specs: + if kind == "runfile": + embedded_args, transformed_args = launcher.append_raw_transformed_arg( + arg = value, + embedded_args = embedded_args, + transformed_args = transformed_args, + ) + else: + embedded_args, transformed_args = launcher.append_embedded_arg( + arg = value, + embedded_args = embedded_args, + transformed_args = transformed_args, + ) + + # The stub holds 10 embedded arguments of at most 256 bytes each. Exceeding either + # is a build failure inside the finalizer, so degrade to the bash launcher instead: + # a deeply nested external repository can produce an rlocation path that long. + if len(embedded_args) > _MAX_EMBEDDED_ARGS: + return None + for arg in embedded_args: + if len(arg) > _MAX_EMBEDDED_ARG_LENGTH: + return None + + # Declared only now that it is certain to be written: a file declared on a path that + # returns None above would have no generating action, which fails analysis rather + # than degrading to the bash launcher. + # + # Windows needs the .exe suffix for this to be executable at all. It gets a + # directory to itself so that its basename can be the target's own name, which is + # what a reader of `ps` output sees. + # + # NB: a js_binary named `hermetic` cannot work, since the bash launcher would be + # the file `hermetic_/hermetic` and this the directory `hermetic_/hermetic/`. + output = ctx.actions.declare_file("{}_/hermetic/{}{}".format( + ctx.label.name, + ctx.label.name, + ".exe" if is_windows else "", + )) + + _compile_stub(ctx, embedded_args, transformed_args, output) + return output + def _bash_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_prefix_rule, fixed_args, fixed_env, is_windows): # Explicitly disable node fs patches on Windows: # https://github.com/aspect-build/rules_js/issues/1137 @@ -387,9 +673,6 @@ def _bash_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_pre if ctx.attr.preserve_symlinks_main and "--preserve-symlinks-main" not in node_options: node_options.append(_NODE_OPTION.format(value = "--preserve-symlinks-main")) - if ctx.attr.expand_args: - fixed_args = [expand_variables(ctx, expand_locations(ctx, fixed_arg, ctx.attr.data)) for fixed_arg in fixed_args] - node_wrapper = ctx.file._node_wrapper_bat if is_windows else ctx.file._node_wrapper_sh toolchain_files = [node_wrapper] @@ -421,6 +704,7 @@ def _bash_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_pre "{{log_prefix_rule}}": log_prefix_rule, "{{node_options}}": "\n".join(node_options), "{{node_patches}}": ctx.file._node_patches.short_path, + "{{node_patches_rlocation}}": to_rlocation_path(ctx, ctx.file._node_patches), "{{node_wrapper}}": node_wrapper.short_path, "{{node}}": node_path, "{{npm}}": npm_path, @@ -455,11 +739,21 @@ def _create_launcher(ctx, log_prefix_rule_set, log_prefix_rule, fixed_args = [], ctx.attr.entry_point[DirectoryPathInfo].directory.short_path, ctx.attr.entry_point[DirectoryPathInfo].path, ]) + entry_point_rlocation = "/".join([ + to_rlocation_path(ctx, entry_point), + ctx.attr.entry_point[DirectoryPathInfo].path, + ]) else: if len(ctx.files.entry_point) != 1: fail("entry_point must be a single file or a target that provides a DirectoryPathInfo") entry_point = ctx.files.entry_point[0] entry_point_path = entry_point.short_path + entry_point_rlocation = to_rlocation_path(ctx, entry_point) + + # Expanded here rather than in _bash_launcher so that the hermetic launcher embeds + # the same arguments the script would have run with, not the unexpanded spelling. + if ctx.attr.expand_args: + fixed_args = [expand_variables(ctx, expand_locations(ctx, fixed_arg, ctx.attr.data)) for fixed_arg in fixed_args] bash_launcher, toolchain_files = _bash_launcher(ctx, nodeinfo, entry_point_path, log_prefix_rule_set, log_prefix_rule, fixed_args, fixed_env, is_windows) launcher = create_windows_native_launcher_script(ctx, bash_launcher) if is_windows else bash_launcher @@ -470,6 +764,17 @@ def _create_launcher(ctx, log_prefix_rule_set, log_prefix_rule, fixed_args = [], launcher_files.append(nodeinfo.node) launcher_files.extend(ctx.files._node_patches_files + [ctx.file._node_patches]) + + # Neither the hermetic launcher nor its preload goes into runfiles: the launcher is + # not built unless its output group is requested, and whoever requests it is + # responsible for putting launcher.cjs in the runfiles the launcher will resolve + # against. Carrying the preload here instead would put it in every js_binary's + # runfiles, and into every container image built from one, for the benefit of the + # few that use it. + fixed_arg_specs, fixed_arg_blockers = _classify_fixed_args(fixed_args) + blockers = _hermetic_launcher_blockers(ctx, fixed_arg_blockers, fixed_env, is_windows) + hermetic_launcher = _hermetic_launcher(ctx, nodeinfo, entry_point_rlocation, fixed_arg_specs, blockers, is_windows) + transitive_launcher_files = None if ctx.attr.include_npm: transitive_launcher_files = nodeinfo.npm_sources @@ -502,8 +807,26 @@ def _create_launcher(ctx, log_prefix_rule_set, log_prefix_rule, fixed_args = [], executable = launcher, runfiles = runfiles, data_runfiles = data_runfiles, + # Deliberately not in runfiles: nothing builds this unless it is requested + # through the output group of the same name. + hermetic_launcher = hermetic_launcher, + hermetic_launcher_blockers = blockers, ) +def _hermetic_launcher_report(ctx, launcher): + """A one-line verdict on this target's hermetic launcher, for the output group.""" + if launcher.hermetic_launcher: + verdict = "eligible" + elif launcher.hermetic_launcher_blockers: + verdict = "blocked: {}".format(",".join(launcher.hermetic_launcher_blockers)) + else: + # No blocker but no launcher either: hermetic_launcher publishes no stub for + # this platform, or an embedded argument was too long for one. + verdict = "unavailable" + report = ctx.actions.declare_file("{}_/hermetic_launcher_report.txt".format(ctx.label.name)) + ctx.actions.write(report, "{} {}\n".format(ctx.label, verdict)) + return report + def _js_binary_impl(ctx): launcher = _create_launcher( ctx, @@ -579,6 +902,17 @@ def _js_binary_impl(ctx): # toolchain scaffolding. Consumed by js_run_binary when # use_execroot_entry_point is enabled. execroot_data_files = launcher.data_runfiles.files, + # A launcher binary that runs the entry point on a patched node, in place of + # the bash launcher script. Not used by `bazel run` and not in runfiles, so + # it only gets stamped when explicitly requested. Empty on platforms + # hermetic_launcher publishes no stub for. + hermetic_launcher = depset( + [launcher.hermetic_launcher] if launcher.hermetic_launcher else [], + ), + # Why this target has no hermetic launcher, for + # `bazel build --output_groups=hermetic_launcher_report //...` to collect + # into a picture of what is blocking adoption. Written only on request. + hermetic_launcher_report = depset([_hermetic_launcher_report(ctx, launcher)]), ), ] @@ -625,6 +959,12 @@ js_binary_lib = struct( # Optional: only referenced on Windows config_common.toolchain_type("@bazel_tools//tools/sh:toolchain_type", mandatory = False), "@rules_nodejs//nodejs:runtime_toolchain_type", + # Optional: only needed to stamp the hermetic_launcher output group, and + # hermetic_launcher publishes no stub for some platforms rules_js supports + # (linux ppc64le, windows arm64). Requiring these would stop js_binary from + # building there at all. + config_common.toolchain_type(_FINALIZER_TOOLCHAIN_TYPE, mandatory = False), + config_common.toolchain_type(_TEMPLATE_TOOLCHAIN_TYPE, mandatory = False), ] + COPY_FILE_TO_BIN_TOOLCHAINS, ) diff --git a/js/private/js_binary.sh.tpl b/js/private/js_binary.sh.tpl index efddebba31..7820d1fb68 100644 --- a/js/private/js_binary.sh.tpl +++ b/js/private/js_binary.sh.tpl @@ -356,7 +356,12 @@ if [ "${JS_BINARY__NO_RUNFILES:-}" ]; then export JS_BINARY__NODE_PATCHES JS_BINARY__NODE_PATCHES=$(resolve_execroot_src_path "{{node_patches}}") else - export JS_BINARY__NODE_PATCHES="$JS_BINARY__RUNFILES/{{workspace_name}}/{{node_patches}}" + # Spelled as a runfiles-root-relative path rather than via + # "{{workspace_name}}/../", so that this is byte-for-byte the path the node + # launcher binary passes to --require. A child process inherits this one in + # execArgv and gets the launcher's as well; if the two spellings differ, node + # loads the patches twice and the second application fails. + export JS_BINARY__NODE_PATCHES="$JS_BINARY__RUNFILES/{{node_patches_rlocation}}" fi if [ ! -f "$JS_BINARY__NODE_PATCHES" ]; then logf_fatal "node patches '%s' not found" "$JS_BINARY__NODE_PATCHES" diff --git a/js/private/js_run_binary.bzl b/js/private/js_run_binary.bzl index 38a1bc5d1d..bdb0f77517 100644 --- a/js/private/js_run_binary.bzl +++ b/js/private/js_run_binary.bzl @@ -13,9 +13,65 @@ load("@aspect_rules_js//js:defs.bzl", "js_run_binary") load("@bazel_lib//lib:copy_to_bin.bzl", _copy_to_bin = "copy_to_bin") load("@bazel_lib//lib:run_binary.bzl", _run_binary = "run_binary") load("@bazel_lib//lib:utils.bzl", bazel_lib_utils = "utils") +load(":hermetic_tool.bzl", _hermetic_tool = "hermetic_tool") load(":js_helpers.bzl", _envs_for_log_level = "envs_for_log_level") load(":js_info_files.bzl", _js_info_files = "js_info_files") +def _hermetic_launcher_usable(args, tool): + """Whether this js_run_binary could use the tool's hermetic launcher. + + Only about what is visible here; whether the tool actually has a launcher is + decided when it is analyzed, and hermetic_tool falls back if it does not. + + Anything it looks at that may be a `select()` is treated as disqualifying rather + than guessed at, since a macro cannot see through one. + """ + + # `log_level` is deliberately absent. It only selects how much diagnostic output is + # printed, and js_run_binary passes JS_BINARY__LOG_* through the action environment, + # so the preload and bootstrap.cjs honour it either way. See the matching note in + # _hermetic_launcher_blockers. + + # `env` is deliberately not inspected. A few JS_BINARY__* variables select launcher + # script behaviour that this launcher cannot reproduce -- the output captures, the + # exit code file, silent_on_success -- but setting one of those by hand means going + # out of the way to ask for something js_run_binary already has an attribute for, and + # launcher.cjs refuses to run at all when it sees one, naming the variable. A loud + # error for something nobody does is worth more than keeping every target that + # mentions JS_BINARY__* on the slow path. The variables the launcher does honour -- + # JS_BINARY__CHDIR, JS_BINARY__NO_CD_BINDIR, JS_BINARY__LOG_* and + # JS_BINARY__USE_EXECROOT_ENTRY_POINT -- work either way. + + # The launcher script turns these into node CLI flags; nothing can do that once + # node has started, so they would both fail to apply and leak into argv. + if type(args) != "list": + return False + for arg in args: + if type(arg) != "string" or arg.startswith("--node_options="): + return False + + # The wrapper has to name the tool, so the tool cannot itself be a select(). + return type(tool) in ["string", "Label"] + +def _hermetic_tool_for(tool, testonly): + """Declares, once per package, a hermetic_tool wrapping `tool`. + + Shared across every js_run_binary in the package that uses the same tool, because + each wrapper gets its own runfiles tree and a tool with a large node_modules is + exactly the kind that many targets share. + """ + sanitized = "".join([c if c.isalnum() else "_" for c in str(tool).elems()]) + name = "_hermetic_tool_{}{}".format(sanitized, "_testonly" if testonly else "") + if native.existing_rule(name) == None: + _hermetic_tool( + name = name, + binary = tool, + testonly = testonly, + # Only build it when a target that uses it is built. + tags = ["manual"], + ) + return ":{}".format(name) + def js_run_binary( name, tool, @@ -414,9 +470,33 @@ See https://github.com/aspect-build/rules_js/tree/main/docs#using-binaries-publi "//conditions:default": {}, }) if use_execroot_entry_point == None else {} + # Run through the tool's hermetic launcher where that is known to behave the same, + # skipping the shell and the path resolution the launcher script does per action. + # hermetic_tool falls back to the bash launcher for a tool that has no hermetic + # launcher, so this only has to decide what is knowable here. + # + # One launcher serves both entry point modes. It resolves the execroot entry point at + # startup from JS_BINARY__USE_EXECROOT_ENTRY_POINT, which is in the action environment + # either way -- fixed_env above for True, the execroot_env select for None -- so + # use_execroot_entry_point no longer has to be knowable here. + run_binary_tool = tool + if _hermetic_launcher_usable(args, tool): + run_binary_tool = _hermetic_tool_for(tool, kwargs.get("testonly")) + + # stdout, stderr, exit_code_out and silent_on_success are forwarded to run_binary, + # which captures through its spawn_binary wrapper, rather than being turned into + # JS_BINARY__* variables for the launcher script to act on. The wrapper outlives the + # program, so it can do the post-exit work the hermetic launcher cannot -- which is + # what lets these four stop disqualifying it. It costs no process either: the script + # only forks rather than execs because of this work, so the wrapper's fork replaces + # the script's. The capture files are declared by run_binary's own attributes and so + # must not also appear in `outs`. + # + # The script keeps its implementation of all four, for anything that invokes a + # js_binary directly and sets those variables itself. _run_binary( name = name, - tool = tool, + tool = run_binary_tool, env = fixed_env | legacy_env | execroot_env | env, srcs = srcs + extra_srcs + execroot_extra_srcs, outs = outs, diff --git a/js/private/node-bootstrap/BUILD.bazel b/js/private/node-bootstrap/BUILD.bazel index d074b6d1a1..060f5e1f03 100644 --- a/js/private/node-bootstrap/BUILD.bazel +++ b/js/private/node-bootstrap/BUILD.bazel @@ -10,4 +10,5 @@ write_source_files( exports_files([ "fs.cjs", "bootstrap.cjs", + "launcher.cjs", ]) diff --git a/js/private/node-bootstrap/bootstrap.cjs b/js/private/node-bootstrap/bootstrap.cjs index 85d67ddf90..e973340a2c 100644 --- a/js/private/node-bootstrap/bootstrap.cjs +++ b/js/private/node-bootstrap/bootstrap.cjs @@ -18,9 +18,14 @@ if (!process.env.JS_BINARY__NODE_PATCHES_DEPTH) { } // subprocess patch +// +// Only when a wrapper was supplied: the bash launcher always sets this, but a +// launcher that invokes node directly has no wrapper to point at. Assigning an +// unset value here would leave process.execPath undefined, which breaks anything +// that re-spawns itself through it. if (process.platform == 'win32') { // FIXME: need to make an exe, or run in a shell so we can use .bat -} else { +} else if (JS_BINARY__NODE_WRAPPER) { if (JS_BINARY__LOG_DEBUG) { console.error( `DEBUG: ${JS_BINARY__LOG_PREFIX}: overriding process.execPath to node wrapper path ${JS_BINARY__NODE_WRAPPER}` diff --git a/js/private/node-bootstrap/launcher.cjs b/js/private/node-bootstrap/launcher.cjs new file mode 100644 index 0000000000..4bc2e23938 --- /dev/null +++ b/js/private/node-bootstrap/launcher.cjs @@ -0,0 +1,430 @@ +// Preload for the hermetic launcher, which invokes node directly rather than +// through js_binary.sh.tpl. It reconstructs the parts of that script's runtime +// contract that a process which only execve's cannot set up, then hands off to +// bootstrap.cjs. +// +// Everything here is derived from process.execPath, process.execArgv, __dirname, +// process.cwd() and the environment, so this file needs no per-target generation. +// __dirname is what makes that work: this file always sits at +// //js/private/node-bootstrap/, whatever the repo is called. +// +// The per-target constants the bash launcher bakes in -- JS_BINARY__WORKSPACE, +// JS_BINARY__TARGET, JS_BINARY__PACKAGE, JS_BINARY__BUILD_FILE_PATH, +// JS_BINARY__COMPILATION_MODE, JS_BINARY__TARGET_CPU, JS_BINARY__BINDIR -- are +// deliberately not reconstructed. They are unreachable from here. +// +// Ordering matters: bootstrap.cjs destructures process.env at module load, so +// every variable it reads has to be set before the require at the bottom. + +const fs = require('fs') +const path = require('path') +const { pathToFileURL } = require('url') + +// A module loaded under two path spellings runs twice, and fs.cjs throws rather +// than patch twice. Make the second run a no-op. +const GUARD = Symbol.for('aspect_rules_js.hermetic_launcher') + +// Node worker threads inherit execArgv, so this preload runs in every one of them, and +// two of the things it does belong to the main thread alone: +// +// - Changing directory. process.chdir throws ERR_WORKER_UNSUPPORTED_OPERATION in a +// worker, which does not need it anyway: it inherits the cwd the main thread +// already moved to. +// - Redirecting the main module. A worker's main is the script it was started with +// rather than the js_binary entry point, and process.argv[1] is not even set there. +// +// Everything else here still has to happen in a worker, because the fs patches apply per +// realm. +// +// The bash launcher never met any of this: the shell did its cd before node started, and +// nothing inside node changed directory or chose a main afterwards. rollup running terser +// is a real target that does both. +const IS_MAIN_THREAD = require('worker_threads').isMainThread + +const LOG_PREFIX = 'aspect_rules_js[js_binary]' + +// The runfiles directory name of the main repository. rules_js is bzlmod-only, and +// bzlmod always names it `_main` -- which is what both bazel-lib's to_rlocation_path and +// hermetic_launcher itself already assume. If it ever were something else the derived +// entry point would not exist, and redirectMainToExecroot fatals naming the path it +// looked for rather than running the wrong file. +const MAIN_REPO_PREFIX = '_main/' + +// Features of the bash launcher that this one cannot implement: each needs work after +// the program exits, and this launcher is execve'd over. Nothing in rules_js sets them +// -- js_run_binary's stdout, stderr, exit_code_out and silent_on_success go through +// run_binary's wrapper, and expected_exit_code keeps a js_binary off this launcher +// altogether -- so reaching one means an `env` that asked for the script's +// implementation of it by hand. Say so rather than silently not writing an output file +// or not suppressing output. +const UNSUPPORTED = [ + 'JS_BINARY__STDOUT_OUTPUT_FILE', + 'JS_BINARY__STDERR_OUTPUT_FILE', + 'JS_BINARY__EXIT_CODE_OUTPUT_FILE', + 'JS_BINARY__EXPECTED_EXIT_CODE', + 'JS_BINARY__SILENT_ON_SUCCESS', +] + +function fatal(message) { + process.stderr.write(`FATAL: ${LOG_PREFIX}: ${message}\n`) + process.exit(1) +} + +function debug(message) { + if (process.env.JS_BINARY__LOG_DEBUG) { + process.stderr.write(`DEBUG: ${LOG_PREFIX}: ${message}\n`) + } +} + +function isDirectory(p) { + try { + return fs.statSync(p).isDirectory() + } catch { + return false + } +} + +// Port of resolve_execroot_bin_path in js_binary.sh.tpl. +function resolveExecrootBinPath(shortPath, execroot) { + const bindir = process.env.BAZEL_BINDIR || process.env.JS_BINARY__BINDIR || '' + return shortPath.startsWith('../') + ? path.join(execroot, bindir, 'external', shortPath.slice(3)) + : path.join(execroot, bindir, shortPath) +} + +// The launcher passes `--bazel-bindir ` ahead of the program's own +// arguments and expects it to be consumed here; if it survived into process.argv +// the program would read it as a positional argument. +// +// Not necessarily at index 2: the js_binary's `fixed_args` are embedded in the +// launcher binary, so they come first, exactly as the launcher script puts them +// before the arguments it was invoked with. Which is why a fixed arg spelled +// `--bazel-bindir` keeps a target off this launcher -- it would be found here +// instead of the real one. +// +// Returns whether the flag was there, which is also the answer to "is this the +// process the build action launched?". js_run_binary appends it to every action, and it +// is consumed here, so a child process that re-enters node -- through the node wrapper +// on the PATH, with this file preloaded again -- never sees it. Only BAZEL_BINDIR's +// value is inherited. +function takeBazelBindir() { + const at = process.argv.indexOf('--bazel-bindir', 2) + if (at === -1) { + return false + } + if (process.argv.length < at + 2) { + fatal('--bazel-bindir flag requires a value') + } + process.env.BAZEL_BINDIR = process.argv[at + 1] + process.argv.splice(at, 2) + return true +} + +// The bash launcher turns these into node CLI flags. By the time a preload runs +// node has already parsed its options, so they can only be rejected. +function rejectNodeOptions() { + for (const arg of process.argv.slice(2)) { + if (arg.startsWith('--node_options=')) { + fatal( + `${arg} is not supported by this launcher; set node options at ` + + `build time with the js_binary node_options attribute` + ) + } + } +} + +// Port of BASH_INITIALIZE_RUNFILES in js/private/bash.bzl, minus the cases that +// cannot arise here: the launcher binary always hands us RUNFILES_DIR or +// RUNFILES_MANIFEST_FILE, so there is no $0 walk. +function resolveRunfiles(startCwd) { + let runfiles = process.env.RUNFILES_DIR + if (!runfiles && process.env.RUNFILES_MANIFEST_FILE) { + const manifest = process.env.RUNFILES_MANIFEST_FILE + if (manifest.endsWith('.runfiles_manifest')) { + runfiles = manifest.slice(0, -'_manifest'.length) + } else if (manifest.endsWith('/MANIFEST')) { + runfiles = manifest.slice(0, -'/MANIFEST'.length) + } else { + fatal(`Unexpected RUNFILES_MANIFEST_FILE value ${manifest}`) + } + } + if (!runfiles) { + fatal('RUNFILES_DIR environment variable is not set') + } + // Must be absolute: we may be about to change directory. + return path.resolve(startCwd, runfiles) +} + +// Port of the execroot derivation and `cd $BAZEL_BINDIR` in js_binary.sh.tpl. +// Kept structurally identical to the bash, because the shape of the condition is +// what makes the three cases work without asking which one we are in: under +// `bazel run` the cwd is inside the runfiles tree and there is no BAZEL_BINDIR, +// in a build action the cwd is the execroot, and a nested js_binary already +// sitting in the bindir declines to change directory a second time. +function resolveExecroot(startCwd) { + const segments = ['/bazel-out/', '/BAZEL-~1/', '/bazel-~1/'] + const segment = segments.find((s) => startCwd.includes(s)) + const bindir = process.env.BAZEL_BINDIR + const inherited = process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT + ? process.env.JS_BINARY__EXECROOT + : undefined + + if (segment && (!bindir || !isDirectory(path.join(startCwd, bindir)))) { + // In the runfiles tree and the execroot is not yet known; strip from the + // last bazel-out segment. + return inherited || startCwd.slice(0, startCwd.lastIndexOf(segment)) + } + + const execroot = inherited || startCwd + if (!process.env.JS_BINARY__NO_CD_BINDIR && IS_MAIN_THREAD) { + if (!bindir) { + fatal( + 'BAZEL_BINDIR must be set in environment to the makevar $(BINDIR) in js_binary ' + + 'build actions (which run in the execroot) so that build actions can change ' + + 'directories to always run out of the root of the Bazel output tree. If this ' + + "is not a build action you can set BAZEL_BINDIR to '.' instead to suppress " + + 'this error.' + ) + } + debug(`changing directory to BAZEL_BINDIR (root of Bazel output tree) ${bindir}`) + process.chdir(bindir) + } + return execroot +} + +// Every child process that re-enters node has to be able to find a patched one. +function setUpNode() { + // Read before bootstrap.cjs overwrites process.execPath with the wrapper. + process.env.JS_BINARY__NODE_BINARY = process.execPath + + // Taken back out of execArgv rather than recomputed, so that it is byte-for-byte + // the string the launcher passed. A child that inherits execArgv and also picks + // this up from the node wrapper would otherwise load two spellings of the same + // module and fs.cjs would throw on the second patch. + const requireIndex = process.execArgv.indexOf('--require') + const preload = + requireIndex !== -1 && process.execArgv[requireIndex + 1] + ? process.execArgv[requireIndex + 1] + : __filename + process.env.JS_BINARY__NODE_PATCHES = preload + + // Derived from the preload's own runfiles path rather than from __dirname, which + // node has already resolved through the runfiles symlink back to the source tree. + // The wrapper has to stay inside the runfiles tree the launcher resolved against. + const wrapper = path.join(path.dirname(preload), '..', 'node_bin', 'node') + if (!fs.existsSync(wrapper)) { + fatal(`node wrapper '${wrapper}' not found`) + } + process.env.JS_BINARY__NODE_WRAPPER = wrapper + + // So that a child process which shells out to `node` gets the patched runtime. + process.env.PATH = process.env.PATH + ? `${path.dirname(wrapper)}${path.delimiter}${process.env.PATH}` + : path.dirname(wrapper) +} + +// Whether node will load `file` through the ESM loader rather than the CJS one, by the +// same rule node uses: the extension, or failing that the nearest package.json "type". +// Only consulted on the re-exec path below, so it stays off the fast path. +function isEsmMain(file) { + if (file.endsWith('.mjs')) { + return true + } + if (file.endsWith('.cjs')) { + return false + } + for (let dir = path.dirname(file); ; ) { + const manifest = path.join(dir, 'package.json') + if (fs.existsSync(manifest)) { + try { + return JSON.parse(fs.readFileSync(manifest, 'utf8')).type === 'module' + } catch { + return false + } + } + const parent = path.dirname(dir) + if (parent === dir) { + return false + } + dir = parent + } +} + +// A node old enough to lack module.registerHooks gives a preload no way to redirect an +// ESM main, so run a second node on the right file rather than let this one load the +// runfiles copy and resolve the program's imports against the wrong tree. Costs a node +// startup, and only for an ESM entry point on node < 22.15. +function reExecOnEntryPoint(entryPoint) { + const { spawnSync } = require('child_process') + debug(`re-executing node on ${entryPoint}: this node cannot redirect an ESM main`) + // The child's argv carries no --bazel-bindir -- takeBazelBindir already removed it -- + // so this file will not try to redirect its main a second time. + const result = spawnSync( + process.execPath, + [...process.execArgv, entryPoint, ...process.argv.slice(2)], + { stdio: 'inherit' } + ) + if (result.error) { + fatal(`could not re-execute node on '${entryPoint}': ${result.error.message}`) + } + if (result.signal) { + process.kill(process.pid, result.signal) + } + process.exit(result.status === null ? 1 : result.status) +} + +// Port of the entry point selection in js_binary.sh.tpl. The launcher binary can only +// bake a runfiles rlocation -- its one argument transformation resolves against the +// runfiles root and nothing else -- so when the caller asked for the execroot entry point +// the bindir copy has to be found here, and node has to be told to treat it as the main +// module. +// +// Which copy runs is the whole of the difference between the two modes. node resolves +// `node_modules` by walking up from the directory of the main module, so this is what +// decides whether the program sees the tool's runfiles tree or the target-configuration +// bin tree that the action's srcs and outputs also live in. +function redirectMainToExecroot(runfiles, execroot) { + // The launcher script builds this from $BAZEL_BINDIR and fails without it. Here the + // consequence of carrying on would be worse than an error: resolveExecrootBinPath + // would join an empty bindir and land on the source tree. + if (!process.env.BAZEL_BINDIR) { + fatal( + 'BAZEL_BINDIR must be set in environment when JS_BINARY__USE_EXECROOT_ENTRY_POINT is set' + ) + } + + // argv[1] is the runfiles path the launcher resolved, and an rlocation path differs + // from a short path only in its first segment, so the short path this needs is + // recoverable without spending an embedded argument on it. + const requested = process.argv[1] + const rlocation = path.relative(runfiles, requested) + const shortPath = rlocation.startsWith(MAIN_REPO_PREFIX) + ? rlocation.slice(MAIN_REPO_PREFIX.length) + : '../' + rlocation + const entryPoint = resolveExecrootBinPath(shortPath, execroot) + if (!fs.existsSync(entryPoint)) { + fatal(`the entry_point '${entryPoint}' not found`) + } + debug(`using the execroot entry point ${entryPoint}`) + + // What the launcher script hands node, so a program reading argv[1] sees the same + // path either way. + process.argv[1] = entryPoint + + const Module = require('module') + + // node captured the main it was given before any preload ran, but it has not resolved + // it yet -- so the resolution is the hook. Returning the bindir path here is what + // makes it the main module, with its own directory as the module search root, and + // nothing is realpath'd on the way, which is what --preserve-symlinks-main would + // otherwise have been doing for the entry point. + const resolveFilename = Module._resolveFilename + Module._resolveFilename = function (request, parent, isMain, options) { + if (isMain) { + Module._resolveFilename = resolveFilename + return entryPoint + } + return resolveFilename.apply(this, arguments) + } + + // An ESM main never reaches that hook: node resolves it through the ESM loader, which + // has its own. Both are installed rather than choosing between them, so that neither + // this file nor js_binary has to work out which loader node will pick. + if (typeof Module.registerHooks !== 'function') { + if (isEsmMain(entryPoint)) { + reExecOnEntryPoint(entryPoint) + } + return + } + const requestedUrl = pathToFileURL(requested).href + const entryPointUrl = pathToFileURL(entryPoint).href + Module.registerHooks({ + resolve(specifier, context, nextResolve) { + if (context.parentURL === undefined && specifier === requestedUrl) { + return { url: entryPointUrl, shortCircuit: true } + } + return nextResolve(specifier, context) + }, + }) +} + +function main() { + for (const name of UNSUPPORTED) { + if (process.env[name]) { + fatal(`${name} is set, which this launcher does not implement`) + } + } + + // Everything below is computed against the directory we started in, which + // resolveExecroot may change. + const startCwd = process.cwd() + + const isActionLaunch = takeBazelBindir() + rejectNodeOptions() + + const runfiles = resolveRunfiles(startCwd) + process.env.RUNFILES_DIR = runfiles + process.env.JS_BINARY__RUNFILES = runfiles + + const execroot = resolveExecroot(startCwd) + process.env.JS_BINARY__EXECROOT = execroot + + // Only the process the action launched has an entry point to choose; a child that + // re-enters node was given its own script to run, and the launcher script would not + // have touched that either -- it runs once per action, not once per node process. + // + // js_binary.sh.tpl also takes the execroot entry point when JS_BINARY__NO_RUNFILES is + // set. That cannot arise here: the launcher binary needs a runfiles tree to resolve + // its own arguments against, and js_binary keeps a target with runfiles disabled off + // this launcher. + if ( + isActionLaunch && + IS_MAIN_THREAD && + process.env.JS_BINARY__USE_EXECROOT_ENTRY_POINT + ) { + redirectMainToExecroot(runfiles, execroot) + } + + setUpNode() + + // Don't override a value set by an outer js_binary; without this bootstrap.cjs + // applies no fs patches at all. + if (!process.env.JS_BINARY__FS_PATCH_ROOTS) { + // ':' rather than path.delimiter, because bootstrap.cjs splits on ':'. + process.env.JS_BINARY__FS_PATCH_ROOTS = `${execroot}:${runfiles}` + } + // JS_BINARY__PATCH_NODE_FS is deliberately not defaulted here. It is a per-target + // value the launcher binary has no way to carry, and js_run_binary always sets it + // explicitly -- to "0" as well as to "1" -- so in a build action the environment is + // already authoritative. Inventing a default here would override `patch_node_fs = + // False` rather than honour it. + if (!process.env.JS_BINARY__LOG_PREFIX) { + process.env.JS_BINARY__LOG_PREFIX = LOG_PREFIX + } + + // aspect-build/rules_js#2937 + if (!process.env.NODE_COMPILE_CACHE && !process.env.NODE_DISABLE_COMPILE_CACHE) { + process.env.NODE_DISABLE_COMPILE_CACHE = '1' + } + + // Also once per action, and for the same reason as the entry point above: a child + // that re-enters node has already inherited the directory, and chdir'ing again would + // resolve this relative path against it, looking for /. The `cd` in + // resolveExecroot is already safe against that by construction, since it tests for + // the bindir below the cwd before moving. + if (process.env.JS_BINARY__CHDIR && IS_MAIN_THREAD && isActionLaunch) { + const target = process.env.JS_BINARY__CHDIR + debug(`changing directory to user specified package ${target}`) + process.chdir( + target.startsWith('external/') + ? resolveExecrootBinPath(target, execroot) + : target + ) + } +} + +if (!globalThis[GUARD]) { + globalThis[GUARD] = true + main() + require('./bootstrap.cjs') +} diff --git a/js/private/test/hermetic_launcher/BUILD.bazel b/js/private/test/hermetic_launcher/BUILD.bazel new file mode 100644 index 0000000000..9cc78880f0 --- /dev/null +++ b/js/private/test/hermetic_launcher/BUILD.bazel @@ -0,0 +1,638 @@ +load("@bazel_lib//lib:diff_test.bzl", "diff_test") +load("@bazel_lib//lib:testing.bzl", "assert_contains") +load("@rules_shell//shell:sh_test.bzl", "sh_test") +load("//js:defs.bzl", "js_binary", "js_run_binary") + +# The hermetic launcher is not part of any js_binary's runfiles and nothing builds it +# by default, so it needs a target of its own to be covered at all. + +package(default_testonly = True) + +# Holds one arm of a differential pair to the launcher script. The js_run_binary gate +# cannot see through a select(), so a select()-valued `args` declines the hermetic +# launcher, while the empty list it resolves to leaves the command line the program sees +# unchanged. An empty `env` wrapped the same way used to serve for this, until the gate +# stopped looking at `env` at all. +_BASH_LAUNCHER = select({"//conditions:default": []}) + +js_binary( + name = "bin", + entry_point = "probe.js", +) + +# Pulls the launcher binary out of the output group. This is also the worked +# example of how a caller opts into it. +filegroup( + name = "launcher", + srcs = [":bin"], + output_group = "hermetic_launcher", +) + +# `bin` is in data so that node, bootstrap.cjs, fs.cjs and probe.js land in this test's +# runfiles at the rlocation paths the launcher has baked in. launcher.cjs is not part +# of a js_binary's runfiles, so whoever asks for the launcher supplies it -- this is +# the worked example of that too. +sh_test( + name = "launcher_test", + srcs = ["launcher_test.sh"], + args = ["$(rootpath :launcher)"], + data = [ + ":bin", + ":launcher", + "//js/private/node-bootstrap:launcher.cjs", + ], + # The launcher is untested on Windows, where it must resolve runfiles through + # RUNFILES_MANIFEST_FILE rather than a materialized tree. + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +# Differential test: one js_binary, run as a js_run_binary tool twice. The two reports +# must be byte-identical -- any field that has to be normalized away to make that true +# is a difference in launcher behaviour that belongs in the gate. +# +# Both targets go through the ordinary js_run_binary gate rather than naming a launcher, +# so this exercises the real selection path. The bash arm is held to the script by +# _BASH_LAUNCHER, which leaves the command line -- and everything else the probe reports +# -- identical to the hermetic arm's. launcher_choice_test is what stops this from +# silently becoming a comparison of the hermetic launcher with itself if the gate ever +# changes. +# +# silent_on_success is off on both arms so that neither goes through run_binary's capture +# wrapper, keeping this a comparison of the two launchers and nothing else. + +js_binary( + name = "action_bin", + entry_point = "action_probe.js", +) + +[ + js_run_binary( + name = "report_{}".format(launcher), + srcs = ["action_probe.js"], + outs = [ + "report_{}.json".format(launcher), + "launcher_{}.txt".format(launcher), + ], + args = ["$(rootpath report_{}.json)".format(launcher)] + pin, + log_level = log_level, + silent_on_success = False, + tool = ":action_bin", + # The hermetic launcher resolves its entry point through runfiles, which is + # what use_execroot_entry_point = False means. + use_execroot_entry_point = False, + ) + for (launcher, pin, log_level) in [ + ("bash", _BASH_LAUNCHER, None), + ("hermetic", [], None), + # A raised log level is not a blocker: it only selects how much diagnostic + # output is printed. This is the target that says so. + ("debug", [], "debug"), + ] +] + +# Differential test for `fixed_args`, which the bash launcher expands with a shell and +# the hermetic launcher carries as embedded arguments -- resolving the runfiles one +# itself. Same structure as above: one js_binary, two js_run_binary targets, and the two +# reports must be byte-identical. + +js_binary( + name = "fixed_args_bin", + data = ["fixed_args_data.json"], + entry_point = "fixed_args_probe.js", + # The documented idiom. `$$` keeps Bazel from expanding `$RUNFILES_DIR` at analysis + # time; `$(rlocationpath ...)` is expanded then, leaving the launcher a runfiles + # path to resolve at startup. + fixed_args = [ + "--literal", + "no-expansion-needed", + "--data", + "$$RUNFILES_DIR/$(rlocationpath fixed_args_data.json)", + ], +) + +[ + js_run_binary( + name = "fixed_args_report_{}".format(launcher), + srcs = ["fixed_args_probe.js"], + outs = [ + "fixed_args_report_{}.json".format(launcher), + "fixed_args_launcher_{}.txt".format(launcher), + ], + args = ["$(rootpath fixed_args_report_{}.json)".format(launcher)] + pin, + silent_on_success = False, + tool = ":fixed_args_bin", + use_execroot_entry_point = False, + ) + for (launcher, pin) in [ + ("bash", _BASH_LAUNCHER), + ("hermetic", []), + ] +] + +diff_test( + name = "fixed_args_differential_test", + file1 = "fixed_args_report_bash.json", + file2 = "fixed_args_report_hermetic.json", + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +sh_test( + name = "fixed_args_launcher_choice_test", + srcs = ["launcher_pair_test.sh"], + args = [ + "fixed_args_report", + "$(rootpath fixed_args_launcher_bash.txt)", + "$(rootpath fixed_args_launcher_hermetic.txt)", + ], + data = [ + "fixed_args_launcher_bash.txt", + "fixed_args_launcher_hermetic.txt", + ], + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +# The classifier's negative cases. A `fixed_arg` the launcher cannot reproduce has to +# make the target ineligible; the failure mode if it does not is an argument reaching +# the program with `$RUNFILES_DIR` or the like still in it. +[ + js_binary( + name = "{}_fixed_args_bin".format(name), + entry_point = "probe.js", + fixed_args = fixed_args, + ) + for (name, fixed_args) in [ + # A shell expansion that is not the runfiles root. + ( + "shell", + ["$$HOME/config.json"], + ), + # A runfiles reference with more shell inside it. + ( + "nested", + ["$$RUNFILES_DIR/$$HOME/config.json"], + ), + # Would be consumed by launcher.cjs in place of the real flag. + ( + "bindir_flag", + ["--bazel-bindir"], + ), + # Over the launcher's ten embedded argument slots, five of which are spoken for. + ( + "too_many", + ["--flag-{}".format(i) for i in range(8)], + ), + ] +] + +[ + filegroup( + name = "{}_verdict".format(name), + srcs = [":{}".format(name)], + output_group = "hermetic_launcher_report", + ) + for name in [ + "fixed_args_bin", + "shell_fixed_args_bin", + "nested_fixed_args_bin", + "bindir_flag_fixed_args_bin", + "too_many_fixed_args_bin", + ] +] + +sh_test( + name = "fixed_args_verdict_test", + srcs = ["fixed_args_verdict_test.sh"], + args = [ + "$(rootpath :fixed_args_bin_verdict)", + "eligible", + "$(rootpath :shell_fixed_args_bin_verdict)", + "blocked:JS_BINARY_FIXED_ARGS", + "$(rootpath :nested_fixed_args_bin_verdict)", + "blocked:JS_BINARY_FIXED_ARGS", + "$(rootpath :bindir_flag_fixed_args_bin_verdict)", + "blocked:JS_BINARY_FIXED_ARGS", + # Not "blocked": the arguments are all supportable, there are just too many of + # them for the stub to hold, which is the same degradation as an unsupported + # platform. + "$(rootpath :too_many_fixed_args_bin_verdict)", + "unavailable", + ], + data = [ + ":bindir_flag_fixed_args_bin_verdict", + ":fixed_args_bin_verdict", + ":nested_fixed_args_bin_verdict", + ":shell_fixed_args_bin_verdict", + ":too_many_fixed_args_bin_verdict", + ], + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +# Differential test for a node worker thread. Worker threads inherit execArgv, so the +# preload runs again in the worker -- a second realm, with its own fs to patch and no +# ability to change directory, since process.chdir throws there. Nothing else here +# exercises a second realm, and rollup running terser is a real target that does. + +js_binary( + name = "worker_bin", + data = ["worker_child.js"], + entry_point = "worker_probe.js", +) + +[ + js_run_binary( + name = "worker_report_{}".format(launcher), + srcs = [ + "worker_child.js", + "worker_probe.js", + ], + outs = [ + "worker_report_{}.json".format(launcher), + "worker_launcher_{}.txt".format(launcher), + ], + # Bare filenames, because chdir has already put the working directory where the + # declared outputs are. chdir is also what makes the worker's inherited cwd + # worth asserting: it is the value the worker cannot set for itself. + args = ["worker_report_{}.json".format(launcher)] + pin, + chdir = package_name(), + silent_on_success = False, + tool = ":worker_bin", + use_execroot_entry_point = False, + ) + for (launcher, pin) in [ + ("bash", _BASH_LAUNCHER), + ("hermetic", []), + ] +] + +diff_test( + name = "worker_differential_test", + file1 = "worker_report_bash.json", + file2 = "worker_report_hermetic.json", + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +sh_test( + name = "worker_launcher_choice_test", + srcs = ["launcher_pair_test.sh"], + args = [ + "worker_report", + "$(rootpath worker_launcher_bash.txt)", + "$(rootpath worker_launcher_hermetic.txt)", + ], + data = [ + "worker_launcher_bash.txt", + "worker_launcher_hermetic.txt", + ], + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +diff_test( + name = "differential_test", + file1 = "report_bash.json", + file2 = "report_hermetic.json", + # Same reason as the launcher test above. + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +# Raising the log level adds diagnostic output on stderr and changes nothing the +# program can observe, which is why it does not disqualify a target. +diff_test( + name = "log_level_test", + file1 = "report_hermetic.json", + file2 = "report_debug.json", + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +sh_test( + name = "launcher_choice_test", + srcs = ["launcher_choice_test.sh"], + args = [ + "$(rootpath launcher_bash.txt)", + "$(rootpath launcher_hermetic.txt)", + "$(rootpath launcher_debug.txt)", + ], + data = [ + "launcher_bash.txt", + "launcher_debug.txt", + "launcher_hermetic.txt", + ], + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +# Differential test for the output capture that run_binary performs: stdout, stderr and +# the exit code all go through its spawn_binary wrapper, which forks the launcher and +# does the post-exit work the hermetic stub cannot do for itself. That wrapper is what +# lets these attributes stop disqualifying the hermetic launcher, so the three captured +# files have to come out the same whichever launcher it forked. +# +# silent_on_success is on here, unlike the pairs above, because this pair is about the +# capture path rather than about the launchers in isolation. With both streams captured +# to files it changes nothing they contain; what it does prove is that the combination +# is legal and still reaches the program. + +js_binary( + name = "capture_bin", + entry_point = "capture_probe.js", +) + +[ + js_run_binary( + name = "capture_report_{}".format(launcher), + srcs = ["capture_probe.js"], + outs = ["capture_launcher_{}.txt".format(launcher)], + args = ["$(rootpath capture_launcher_{}.txt)".format(launcher)] + pin, + exit_code_out = "capture_exit_code_{}.txt".format(launcher), + silent_on_success = True, + stderr = "capture_stderr_{}.txt".format(launcher), + stdout = "capture_stdout_{}.txt".format(launcher), + tool = ":capture_bin", + use_execroot_entry_point = False, + ) + for (launcher, pin) in [ + ("bash", _BASH_LAUNCHER), + ("hermetic", []), + ] +] + +[ + diff_test( + name = "capture_{}_differential_test".format(stream), + file1 = "capture_{}_bash.txt".format(stream), + file2 = "capture_{}_hermetic.txt".format(stream), + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + ) + for stream in [ + "stdout", + "stderr", + "exit_code", + ] +] + +# Identical across the two launchers is necessary but not sufficient: both could be +# wrong the same way. This says the code recorded is the one the program chose, and that +# a non-zero exit did not fail the action. +assert_contains( + name = "capture_exit_code_test", + actual = "capture_exit_code_hermetic.txt", + expected = "42", +) + +# Streams must not be crossed, which identical files alone would not catch either. +assert_contains( + name = "capture_stdout_test", + actual = "capture_stdout_hermetic.txt", + expected = "capture: second line on stdout", +) + +assert_contains( + name = "capture_stderr_test", + actual = "capture_stderr_hermetic.txt", + expected = "capture: second line on stderr", +) + +sh_test( + name = "capture_launcher_choice_test", + srcs = ["launcher_pair_test.sh"], + args = [ + "capture_report", + "$(rootpath capture_launcher_bash.txt)", + "$(rootpath capture_launcher_hermetic.txt)", + ], + data = [ + "capture_launcher_bash.txt", + "capture_launcher_hermetic.txt", + ], + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +# Differential test for the execroot entry point. use_execroot_entry_point = True runs the +# bindir copy of the entry point rather than the runfiles one, and the launcher binary +# cannot express that: it bakes a runfiles rlocation, and its one argument transformation +# resolves against the runfiles root. launcher.cjs composes the bindir path at startup and +# redirects node's main to it, so this pair is what says the composed path and the redirect +# together land where the launcher script lands. + +[ + js_run_binary( + name = "execroot_report_{}".format(launcher), + srcs = ["action_probe.js"], + outs = [ + "execroot_report_{}.json".format(launcher), + "execroot_launcher_{}.txt".format(launcher), + ], + args = ["$(rootpath execroot_report_{}.json)".format(launcher)] + pin, + silent_on_success = False, + tool = ":action_bin", + use_execroot_entry_point = True, + ) + for (launcher, pin) in [ + ("bash", _BASH_LAUNCHER), + ("hermetic", []), + ] +] + +diff_test( + name = "execroot_differential_test", + file1 = "execroot_report_bash.json", + file2 = "execroot_report_hermetic.json", + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +sh_test( + name = "execroot_launcher_choice_test", + srcs = ["launcher_pair_test.sh"], + args = [ + "execroot_report", + "$(rootpath execroot_launcher_bash.txt)", + "$(rootpath execroot_launcher_hermetic.txt)", + ], + data = [ + "execroot_launcher_bash.txt", + "execroot_launcher_hermetic.txt", + ], + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +# Agreeing with the bash launcher is necessary but not sufficient -- both could be reading +# the runfiles copy. These name the tree the entry point came from, which is the only +# observable difference between the two modes and the reason the mode exists: node walks up +# from the main module's directory to resolve the program's own requires. +assert_contains( + name = "execroot_search_root_test", + actual = "execroot_report_hermetic.json", + expected = "\"module_search_root\": \"/js/private/test/hermetic_launcher/node_modules\"", +) + +assert_contains( + name = "runfiles_search_root_test", + actual = "report_hermetic.json", + expected = "\"module_search_root\": \"/_main/js/private/test/hermetic_launcher/node_modules\"", +) + +# Differential test for an ESM entry point in execroot mode. node resolves an ESM main +# through the ESM loader, which never reaches the CJS hook, so this is a second redirect +# path rather than a variation on the one above. + +js_binary( + name = "esm_bin", + entry_point = "esm_probe.mjs", +) + +[ + js_run_binary( + name = "esm_report_{}".format(launcher), + srcs = ["esm_probe.mjs"], + outs = [ + "esm_report_{}.json".format(launcher), + "esm_launcher_{}.txt".format(launcher), + ], + args = ["$(rootpath esm_report_{}.json)".format(launcher)] + pin, + silent_on_success = False, + tool = ":esm_bin", + use_execroot_entry_point = True, + ) + for (launcher, pin) in [ + ("bash", _BASH_LAUNCHER), + ("hermetic", []), + ] +] + +diff_test( + name = "esm_differential_test", + file1 = "esm_report_bash.json", + file2 = "esm_report_hermetic.json", + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +sh_test( + name = "esm_launcher_choice_test", + srcs = ["launcher_pair_test.sh"], + args = [ + "esm_report", + "$(rootpath esm_launcher_bash.txt)", + "$(rootpath esm_launcher_hermetic.txt)", + ], + data = [ + "esm_launcher_bash.txt", + "esm_launcher_hermetic.txt", + ], + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +assert_contains( + name = "esm_search_root_test", + actual = "esm_report_hermetic.json", + expected = "\"import_meta_path\": \"/js/private/test/hermetic_launcher/esm_probe.mjs\"", +) + +# copy_data_to_bin is what puts the entry point in the bindir at all, so without it the +# execroot entry point cannot be found. The launcher script says so at runtime, from a +# per-target constant the stub cannot carry, so this has to be decided at analysis time. +js_binary( + name = "no_copy_data_bin", + copy_data_to_bin = False, + entry_point = "probe.js", +) + +filegroup( + name = "no_copy_data_verdict", + srcs = [":no_copy_data_bin"], + output_group = "hermetic_launcher_report", +) + +sh_test( + name = "no_copy_data_verdict_test", + srcs = ["fixed_args_verdict_test.sh"], + args = [ + "$(rootpath :no_copy_data_verdict)", + "blocked:JS_BINARY_COPY_DATA_TO_BIN", + ], + data = [":no_copy_data_verdict"], + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +# `env` is not part of the gate at all: a JS_BINARY__* key in it does not disqualify the +# hermetic launcher, and neither does a select() the macro cannot see through. Both are +# in play here. +# +# What the variable asks for is real rather than inert. This target opts out of the +# execroot entry point through the attribute and then asks for it back through `env`, so +# the only thing that can put the bindir copy in argv[1] is launcher.cjs having honoured +# the variable -- which is what the report assertion below reads. The entry point is in +# `srcs`, so its bindir copy exists without the hoisting that the attribute would have +# done. +js_run_binary( + name = "env_report_hermetic", + srcs = ["action_probe.js"], + outs = [ + "env_launcher_hermetic.txt", + "env_report_hermetic.json", + ], + args = ["$(rootpath env_report_hermetic.json)"], + env = select({ + "//conditions:default": {"JS_BINARY__USE_EXECROOT_ENTRY_POINT": "1"}, + }), + silent_on_success = False, + tool = ":action_bin", + use_execroot_entry_point = False, +) + +assert_contains( + name = "env_launcher_test", + actual = "env_launcher_hermetic.txt", + expected = "launcher.cjs", +) + +assert_contains( + name = "env_execroot_entry_point_test", + actual = "env_report_hermetic.json", + expected = "\"entry_point\": \"/js/private/test/hermetic_launcher/action_probe.js\"", +) diff --git a/js/private/test/hermetic_launcher/action_probe.js b/js/private/test/hermetic_launcher/action_probe.js new file mode 100644 index 0000000000..6068f9a1c9 --- /dev/null +++ b/js/private/test/hermetic_launcher/action_probe.js @@ -0,0 +1,107 @@ +// Entry point for the differential test: the same js_binary is run as a +// js_run_binary tool twice, once through the bash launcher and once through the +// hermetic launcher, and the two reports this writes must be byte-identical. +// +// Everything recorded here is therefore normalized against the runfiles root or the +// execroot, since those differ between the two targets. Anything that cannot be +// normalized away is a difference in launcher behaviour, which is the point. + +const fs = require('fs') +const path = require('path') +const { spawnSync } = require('child_process') + +const execroot = process.env.JS_BINARY__EXECROOT +const runfiles = process.env.JS_BINARY__RUNFILES +const bindir = process.env.BAZEL_BINDIR + +// The one argument is the report path, relative to the bindir. Writing it relative to +// the current directory is the assertion that the launcher left us in the bindir: if +// it did not, Bazel reports the declared output as missing. +// +// Which launcher ran goes in a file of its own rather than in the report, since the +// whole point of the report is that it does not depend on the launcher. Its path is +// derived rather than passed, so that argv itself stays free of anything that differs +// between the two targets. +const out = process.argv[2] +const launcherOut = out.replace('report_', 'launcher_').replace('.json', '.txt') + +function fromRunfiles(p) { + return p ? path.relative(runfiles, p) : null +} + +// Which tree a path is in, rather than where it is on this machine. The two launchers +// reach the same file through differently-named roots, and in execroot mode the entry +// point is not in the runfiles tree at all -- it is the bindir copy. +function normalize(p) { + if (!p) { + return null + } + const bindirRoot = path.join(execroot, bindir) + for (const [name, root] of [ + ['', runfiles], + ['', bindirRoot], + ['', execroot], + ]) { + if (p === root || p.startsWith(root + path.sep)) { + return path.posix.join(name, path.relative(root, p)) + } + } + return p +} + +// Proves the node wrapper is on the PATH and works, which is what keeps a child +// process that shells out to `node` on the patched runtime. +function childExecPath() { + const child = spawnSync('node', ['-p', 'process.execPath'], { encoding: 'utf8' }) + return child.status === 0 ? fromRunfiles(child.stdout.trim()) : `failed: ${child.status}` +} + +const report = { + // The "everything runs from the root of the output tree" contract. + cwd_is_bindir: process.cwd() === path.join(execroot, bindir), + + // --bazel-bindir is for the launcher, not the program. + argv_after_out: process.argv.slice(3), + argv_has_bazel_bindir: process.argv.includes('--bazel-bindir'), + + // In execroot mode this is the bindir copy, which is the whole of what that mode + // means: node resolves the program's own requires by walking up from here, so + // module_search_root below is the assertion that actually matters. + entry_point: normalize(process.argv[1]), + main_is_this_module: require.main === module, + main_filename: normalize(require.main && require.main.filename), + module_search_root: normalize(module.paths[0]), + exec_path: fromRunfiles(process.execPath), + path_first_entry: fromRunfiles((process.env.PATH || '').split(path.delimiter)[0]), + child_exec_path: childExecPath(), + node_wrapper: fromRunfiles(process.env.JS_BINARY__NODE_WRAPPER), + + // Recorded as an invariant rather than a path, because the two launchers legitimately + // differ here and the path would drown out anything that does not: + // + // - JS_BINARY__NODE_BINARY is a runfiles path under the bash launcher, but the + // hermetic one takes it from process.execPath, which node reports as a realpath. + // - JS_BINARY__NODE_PATCHES is bootstrap.cjs under the bash launcher and launcher.cjs + // under the hermetic one, which is the whole point of the preload. + node_binary_exists: fs.existsSync(process.env.JS_BINARY__NODE_BINARY || ''), + node_patches_exists: fs.existsSync(process.env.JS_BINARY__NODE_PATCHES || ''), + + fs_patched: Boolean(fs._unpatched), + // The second root is the runfiles tree, whose directory is named after the tool + // target, and the hermetic run's tool is the wrapper. + patch_roots: (process.env.JS_BINARY__FS_PATCH_ROOTS || '') + .split(':') + .map((root) => (root === runfiles ? '' : path.relative(execroot, root))), + node_patches_depth: process.env.JS_BINARY__NODE_PATCHES_DEPTH, + + preserve_symlinks_main: process.execArgv.includes('--preserve-symlinks-main'), + compile_cache_disabled: process.env.NODE_DISABLE_COMPILE_CACHE, +} + +fs.mkdirSync(path.dirname(out), { recursive: true }) +fs.writeFileSync(out, JSON.stringify(report, null, 2) + '\n') + +// bootstrap.cjs is preloaded directly by the bash launcher; launcher.cjs only by the +// hermetic one. Without this the differential test could compare a launcher to itself +// and pass for the wrong reason. +fs.writeFileSync(launcherOut, path.basename(process.env.JS_BINARY__NODE_PATCHES) + '\n') diff --git a/js/private/test/hermetic_launcher/capture_probe.js b/js/private/test/hermetic_launcher/capture_probe.js new file mode 100644 index 0000000000..8f9ef87fc7 --- /dev/null +++ b/js/private/test/hermetic_launcher/capture_probe.js @@ -0,0 +1,31 @@ +// Entry point for the capture differential test: the same js_binary runs twice, once +// through each launcher, with stdout, stderr and the exit code all captured by +// run_binary's spawn_binary wrapper rather than by the launcher script. The three +// captured files must be byte-identical across the two runs, which is what says the +// wrapper behaves the same whether it is forking the script or the hermetic stub. +// +// Nothing written to the streams may depend on which launcher ran, so paths stay out +// of them. + +const fs = require('fs') +const path = require('path') + +// The one argument is where to record which launcher ran. That cannot go on the +// captured streams, since those are what the differential test compares. +const launcherOut = process.argv[2] + +// Interleaved, so that a wrapper which crossed the two streams would show up as a +// difference in both files rather than as a line missing from one. +process.stdout.write('capture: first line on stdout\n') +process.stderr.write('capture: first line on stderr\n') +process.stdout.write('capture: second line on stdout\n') +process.stderr.write('capture: second line on stderr\n') + +fs.mkdirSync(path.dirname(launcherOut), { recursive: true }) +fs.writeFileSync(launcherOut, path.basename(process.env.JS_BINARY__NODE_PATCHES) + '\n') + +// A non-zero exit the action has to survive: exit_code_out records the code and leaves +// the action successful. Set rather than passed to process.exit() so that node flushes +// the streams on the way out. 42 rather than 1, so a code invented somewhere along the +// chain cannot pass for it. +process.exitCode = 42 diff --git a/js/private/test/hermetic_launcher/esm_probe.mjs b/js/private/test/hermetic_launcher/esm_probe.mjs new file mode 100644 index 0000000000..acc69b2466 --- /dev/null +++ b/js/private/test/hermetic_launcher/esm_probe.mjs @@ -0,0 +1,52 @@ +// Entry point for the ESM execroot differential test. node resolves an ESM main through +// its ESM loader, which never consults the CJS hook launcher.cjs installs for a CJS main, +// so this is the target that says the ESM half of the redirect works. If it did not fire, +// import.meta.url below would name the runfiles copy and the report would stop matching +// the one the launcher script produces. + +import * as fs from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +const execroot = process.env.JS_BINARY__EXECROOT +const runfiles = process.env.JS_BINARY__RUNFILES +const bindir = process.env.BAZEL_BINDIR + +const out = process.argv[2] +const launcherOut = out.replace('report_', 'launcher_').replace('.json', '.txt') + +// Same normalization as action_probe.js: which tree a path is in, not where it is on this +// machine, since the two launchers reach the same file through differently-named roots. +function normalize(p) { + if (!p) { + return null + } + const bindirRoot = path.join(execroot, bindir) + for (const [name, root] of [ + ['', runfiles], + ['', bindirRoot], + ['', execroot], + ]) { + if (p === root || p.startsWith(root + path.sep)) { + return path.posix.join(name, path.relative(root, p)) + } + } + return p +} + +const report = { + cwd_is_bindir: process.cwd() === path.join(execroot, bindir), + + // An ESM module has no require.main and no module.paths to inspect, so its own URL is + // the evidence: it is the copy node actually loaded, and therefore the directory it + // resolves bare specifiers from. + entry_point: normalize(process.argv[1]), + import_meta_path: normalize(fileURLToPath(import.meta.url)), + + argv_after_out: process.argv.slice(3), + argv_has_bazel_bindir: process.argv.includes('--bazel-bindir'), +} + +fs.mkdirSync(path.dirname(out), { recursive: true }) +fs.writeFileSync(out, JSON.stringify(report, null, 2) + '\n') +fs.writeFileSync(launcherOut, path.basename(process.env.JS_BINARY__NODE_PATCHES) + '\n') diff --git a/js/private/test/hermetic_launcher/fixed_args_data.json b/js/private/test/hermetic_launcher/fixed_args_data.json new file mode 100644 index 0000000000..3c46bbcc07 --- /dev/null +++ b/js/private/test/hermetic_launcher/fixed_args_data.json @@ -0,0 +1,3 @@ +{ + "resolved": "through runfiles" +} diff --git a/js/private/test/hermetic_launcher/fixed_args_probe.js b/js/private/test/hermetic_launcher/fixed_args_probe.js new file mode 100644 index 0000000000..0b08c02d15 --- /dev/null +++ b/js/private/test/hermetic_launcher/fixed_args_probe.js @@ -0,0 +1,42 @@ +// Entry point for the fixed_args differential test. The js_binary under test has +// `fixed_args` in the documented form -- a literal flag plus +// "$$RUNFILES_DIR/$(rlocationpath ...)" -- which the bash launcher produces by shell +// expansion and the hermetic launcher by resolving an embedded argument through its +// runfiles. The two reports this writes must be byte-identical. + +const fs = require('fs') +const path = require('path') + +const runfiles = process.env.JS_BINARY__RUNFILES + +// The fixed args come first, exactly as the launcher script orders them, so the report +// path that js_run_binary passes is last. +const args = process.argv.slice(2) +const out = args[args.length - 1] + +// Normalized against the runfiles root, which differs between the two targets: the +// hermetic run's tool is the wrapper, and a runfiles tree is named after its target. +const dataPath = args[args.indexOf('--data') + 1] + +const report = { + // The whole point: an embedded argument the launcher resolved, and the shell + // expansion it has to match. + data_arg_absolute: path.isAbsolute(dataPath), + data_arg: path.relative(runfiles, dataPath), + data_readable: JSON.parse(fs.readFileSync(dataPath, 'utf8')), + + // A fixed arg with nothing to expand has to survive verbatim, and in position. + args_before_out: args + .slice(0, -1) + .map((arg) => (arg === dataPath ? '' : arg)), +} + +fs.mkdirSync(path.dirname(out), { recursive: true }) +fs.writeFileSync(out, JSON.stringify(report, null, 2) + '\n') + +// Same guard as the main differential test: proves the two sides really ran through +// different launchers rather than comparing one launcher with itself. +fs.writeFileSync( + out.replace('report_', 'launcher_').replace('.json', '.txt'), + path.basename(process.env.JS_BINARY__NODE_PATCHES) + '\n' +) diff --git a/js/private/test/hermetic_launcher/fixed_args_verdict_test.sh b/js/private/test/hermetic_launcher/fixed_args_verdict_test.sh new file mode 100755 index 0000000000..9135c360c1 --- /dev/null +++ b/js/private/test/hermetic_launcher/fixed_args_verdict_test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Pins what the fixed_args classifier decides, using the verdict every js_binary +# publishes in its hermetic_launcher_report output group. The differential test covers +# the case that works; these are the cases that must not, because getting them wrong +# means an argument silently reaching the program unexpanded. +# +# Arguments are (report file, expected verdict) pairs. The report line is the target +# label followed by the verdict, and only the verdict is checked -- the label spelling +# depends on how the workspace is being built. Whitespace is squeezed out of both sides +# because a test argument containing a space would not survive as one argument. + +set -o errexit -o nounset -o pipefail + +status=0 + +while [ "$#" -gt 0 ]; do + report="$1" + expected="$2" + shift 2 + + line="$(cat "$report")" + actual="${line#* }" + actual="${actual// /}" + + if [ "$actual" != "$expected" ]; then + echo "FAIL: $report: expected '$expected', got '$actual'" >&2 + status=1 + fi +done + +if [ "$status" -eq 0 ]; then + echo "PASS" +fi + +exit "$status" diff --git a/js/private/test/hermetic_launcher/launcher_choice_test.sh b/js/private/test/hermetic_launcher/launcher_choice_test.sh new file mode 100755 index 0000000000..8ee4beedf6 --- /dev/null +++ b/js/private/test/hermetic_launcher/launcher_choice_test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Guards the differential test: it only means anything if its two sides really ran +# through different launchers. Each side records the preload node was given, which is +# bootstrap.cjs for the bash launcher and launcher.cjs for the hermetic one. + +set -o errexit -o nounset -o pipefail + +bash_launcher="$(cat "$1")" +hermetic_launcher="$(cat "$2")" +debug_launcher="$(cat "$3")" + +if [ "$bash_launcher" != "bootstrap.cjs" ]; then + echo "FAIL: expected report_bash to run through the bash launcher, got preload '$bash_launcher'" >&2 + exit 1 +fi + +if [ "$hermetic_launcher" != "launcher.cjs" ]; then + echo "FAIL: expected report_hermetic to run through the hermetic launcher, got preload '$hermetic_launcher'" >&2 + exit 1 +fi + +# A raised log level must not push a target back onto the bash launcher. +if [ "$debug_launcher" != "launcher.cjs" ]; then + echo "FAIL: expected report_debug to run through the hermetic launcher, got preload '$debug_launcher'" >&2 + exit 1 +fi + +echo "PASS" diff --git a/js/private/test/hermetic_launcher/launcher_pair_test.sh b/js/private/test/hermetic_launcher/launcher_pair_test.sh new file mode 100755 index 0000000000..6ae80a429f --- /dev/null +++ b/js/private/test/hermetic_launcher/launcher_pair_test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# Guards a two-sided differential test: it only means anything if its two sides really +# ran through different launchers. Each side records the preload node was given, which +# is bootstrap.cjs for the bash launcher and launcher.cjs for the hermetic one. +# +# Without this, a change that disqualified the hermetic side would put both sides on +# the bash launcher and the diff_test would still pass. +# +# Arguments: a name for the pair, then its bash-side and hermetic-side record. + +set -o errexit -o nounset -o pipefail + +pair="$1" +bash_launcher="$(cat "$2")" +hermetic_launcher="$(cat "$3")" + +if [ "$bash_launcher" != "bootstrap.cjs" ]; then + echo "FAIL: expected the bash side of $pair to run through the bash launcher, got preload '$bash_launcher'" >&2 + exit 1 +fi + +if [ "$hermetic_launcher" != "launcher.cjs" ]; then + echo "FAIL: expected the hermetic side of $pair to run through the hermetic launcher, got preload '$hermetic_launcher'" >&2 + exit 1 +fi + +echo "PASS" diff --git a/js/private/test/hermetic_launcher/launcher_test.sh b/js/private/test/hermetic_launcher/launcher_test.sh new file mode 100755 index 0000000000..d04ca77aa7 --- /dev/null +++ b/js/private/test/hermetic_launcher/launcher_test.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# Runs the hermetic launcher directly, with no bash launcher involved, and checks +# that launcher.cjs reconstructed the runtime contract that js_binary.sh.tpl would +# otherwise have set up. +# +# Exports only JS_BINARY__PATCH_NODE_FS, which is a per-target value the launcher +# binary cannot carry and which js_run_binary always passes through the action +# environment. Everything else asserted here has to be derived by the preload. + +set -o errexit -o nounset -o pipefail + +export JS_BINARY__PATCH_NODE_FS=1 + +launcher="$1" + +if [ ! -x "$launcher" ]; then + echo "FAIL: '$launcher' is not executable" >&2 + exit 1 +fi + +# No entry point argument -- the launcher already knows it. The --bazel-bindir flag +# is what js_run_binary prepends to every action; the preload must consume it. The +# value names no real directory, which keeps the launcher on the runfiles branch and +# out of a chdir this test is not set up for. +output="$("$launcher" --bazel-bindir not/a/real/bindir hello world)" + +fail() { + echo "FAIL: $1" >&2 + echo "--- launcher output ---" >&2 + echo "$output" >&2 + exit 1 +} + +expect() { + if [[ "$output" != *"$1"$'\n'* && "$output" != *"$1" ]]; then + fail "expected '$1'" + fi +} + +# The flag is consumed, its value is exported, and the program's own arguments survive. +expect "args=hello world" +expect "bazel_bindir=not/a/real/bindir" + +# Without JS_BINARY__FS_PATCH_ROOTS from the preload, bootstrap.cjs patches nothing. +expect "depth=." +expect "fs_patched=yes" +expect "patch_roots_match=yes" + +expect "runfiles_absolute=yes" +expect "execroot_absolute=yes" + +expect "wrapper_is_file=yes" +expect "wrapper_first_on_path=yes" +expect "node_binary_is_file=yes" +expect "node_patches_match=yes" +expect "exec_path_is_file=yes" + +expect "compile_cache_disabled=1" + +# Also what makes a `--` separator before the entry point unnecessary: the launcher +# resolves it by prefixing the absolute runfiles root, so node can never mistake it for +# an option. js_binary.bzl spends no argument slot guarding that. +expect "main_absolute=yes" +expect "preserve_symlinks_main=yes" + +echo "PASS" diff --git a/js/private/test/hermetic_launcher/probe.js b/js/private/test/hermetic_launcher/probe.js new file mode 100644 index 0000000000..35bc8f20ab --- /dev/null +++ b/js/private/test/hermetic_launcher/probe.js @@ -0,0 +1,50 @@ +// The js_binary entry point the hermetic launcher bakes in. Reports what +// launcher.cjs reconstructed, so the test can check it against what the bash +// launcher would have set up. +const fs = require('fs') +const path = require('path') + +const report = (key, value) => console.log(`${key}=${value}`) + +// launcher.cjs must consume `--bazel-bindir ` rather than leave it for the +// program, and everything after it must arrive untouched. +report('args', process.argv.slice(2).join(' ')) +report('bazel_bindir', process.env.BAZEL_BINDIR) + +// bootstrap.cjs only applies the fs patches when both of these are set, and only +// launcher.cjs can set them here. +report('depth', process.env.JS_BINARY__NODE_PATCHES_DEPTH) +report('fs_patched', fs._unpatched ? 'yes' : 'no') +report('patch_roots_match', process.env.JS_BINARY__FS_PATCH_ROOTS === + `${process.env.JS_BINARY__EXECROOT}:${process.env.JS_BINARY__RUNFILES}` ? 'yes' : 'no') + +// These have to be absolute: the launcher may change directory after computing them. +report('runfiles_absolute', path.isAbsolute(process.env.JS_BINARY__RUNFILES || '') ? 'yes' : 'no') +report('execroot_absolute', path.isAbsolute(process.env.JS_BINARY__EXECROOT || '') ? 'yes' : 'no') + +// A child that shells out to `node` has to find the patched wrapper first, and the +// wrapper itself reads these two. +const wrapper = process.env.JS_BINARY__NODE_WRAPPER || '' +report('wrapper_is_file', fs.existsSync(wrapper) ? 'yes' : 'no') +report('wrapper_first_on_path', (process.env.PATH || '').split(path.delimiter)[0] === + path.dirname(wrapper) ? 'yes' : 'no') +report('node_binary_is_file', fs.existsSync(process.env.JS_BINARY__NODE_BINARY || '') ? 'yes' : 'no') + +// Must be byte-identical to what the launcher passed, or a child process loads two +// spellings of the preload and fs.cjs throws on the second patch. +const requireIndex = process.execArgv.indexOf('--require') +report('node_patches_match', + process.env.JS_BINARY__NODE_PATCHES === process.execArgv[requireIndex + 1] ? 'yes' : 'no') + +// bootstrap.cjs points execPath at the wrapper; either way it must name a real file. +report('exec_path_is_file', fs.existsSync(process.execPath) ? 'yes' : 'no') + +// aspect-build/rules_js#2937 +report('compile_cache_disabled', process.env.NODE_DISABLE_COMPILE_CACHE) + +// node resolves and absolutizes the main entry before preloads run, which is what +// lets launcher.cjs change directory without relocating the entry point. +report('main_absolute', path.isAbsolute(process.argv[1]) ? 'yes' : 'no') + +// --preserve-symlinks-main is a node CLI flag, so it can only come from the launcher. +report('preserve_symlinks_main', process.execArgv.includes('--preserve-symlinks-main') ? 'yes' : 'no') diff --git a/js/private/test/hermetic_launcher/worker_child.js b/js/private/test/hermetic_launcher/worker_child.js new file mode 100644 index 0000000000..bd63a61380 --- /dev/null +++ b/js/private/test/hermetic_launcher/worker_child.js @@ -0,0 +1,11 @@ +// Runs in a node worker thread, which inherits execArgv and therefore the preload. + +const fs = require('fs') +const { parentPort } = require('worker_threads') + +parentPort.postMessage({ + cwd: process.cwd(), + fs_patched: Boolean(fs._unpatched), + patch_roots: process.env.JS_BINARY__FS_PATCH_ROOTS, + exec_path: process.execPath, +}) diff --git a/js/private/test/hermetic_launcher/worker_probe.js b/js/private/test/hermetic_launcher/worker_probe.js new file mode 100644 index 0000000000..8122d501a2 --- /dev/null +++ b/js/private/test/hermetic_launcher/worker_probe.js @@ -0,0 +1,57 @@ +// Entry point for the worker-thread differential test. Node worker threads inherit +// execArgv, so whichever preload node was given runs again in the worker -- a second +// realm, with its own fs to patch and no ability to change directory. The two reports +// this writes must be byte-identical. + +const fs = require('fs') +const path = require('path') +const { Worker } = require('worker_threads') + +const out = process.argv[2] +const execroot = process.env.JS_BINARY__EXECROOT +const runfiles = process.env.JS_BINARY__RUNFILES + +// The runfiles tree is named after the tool target, and the hermetic run's tool is the +// wrapper, so anything inside it is reported relative to its root. +function normalize(p) { + if (!p) return null + if (p === runfiles) return '' + if (p.startsWith(runfiles + path.sep)) { + return path.join('', path.relative(runfiles, p)) + } + return path.relative(execroot, p) +} + +const worker = new Worker(path.join(__dirname, 'worker_child.js')) + +worker.on('error', (err) => { + process.stderr.write(`FAIL: worker threw: ${err.stack}\n`) + process.exit(1) +}) + +worker.on('message', (child) => { + const report = { + // This target sets chdir, so the launcher moved here before the worker started. + main_cwd: path.relative(execroot, process.cwd()), + + // The worker has to inherit that, not repeat it: process.chdir throws + // ERR_WORKER_UNSUPPORTED_OPERATION in a worker thread. + worker_cwd: path.relative(execroot, child.cwd), + + // The fs patches are per realm, so the preload has to do its work again here. + worker_fs_patched: child.fs_patched, + worker_patch_roots: (child.patch_roots || '').split(':').map(normalize), + worker_exec_path: normalize(child.exec_path), + } + + fs.writeFileSync(out, JSON.stringify(report, null, 2) + '\n') + + // Same guard as the other differential tests: proves the two sides really ran + // through different launchers. + fs.writeFileSync( + out.replace('report_', 'launcher_').replace('.json', '.txt'), + path.basename(process.env.JS_BINARY__NODE_PATCHES) + '\n' + ) + + worker.terminate() +}) diff --git a/js/private/test/image/checksum_test.expected b/js/private/test/image/checksum_test.expected index c7898a29b5..a9742a2a97 100644 --- a/js/private/test/image/checksum_test.expected +++ b/js/private/test/image/checksum_test.expected @@ -1,4 +1,4 @@ -031bbb0850b697e75e647ab254e4de6511ffd7a249b4b48ca719cb4d8d9c4783 js/private/test/image/cksum_node.tar +08197f6030531d9092d58f7df148f49df42172cc89798d1a05acec9aa3ec1759 js/private/test/image/cksum_node.tar 70b10220a2c05d87da4271c17c38ded8febc725e20e06f1c3809d2bb02ba4ae7 js/private/test/image/cksum_package_store_3p.tar 2cb6f678d6eb0b2e9d5e2637f41ae3f192233752f1ea2a55cede2531deec2a64 js/private/test/image/cksum_package_store_1p.tar 79afa99006aff19460354e72cf2634db693670d09ccc7531fbf27f1f20fe0a5f js/private/test/image/cksum_node_modules.tar diff --git a/js/private/test/image/custom_layers_nomatch_test_node.listing b/js/private/test/image/custom_layers_nomatch_test_node.listing index 60de743170..222653dcbc 100644 --- a/js/private/test/image/custom_layers_nomatch_test_node.listing +++ b/js/private/test/image/custom_layers_nomatch_test_node.listing @@ -8,7 +8,7 @@ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin. drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/private/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/ --r-xr-xr-x 0 0 0 1460 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs +-r-xr-xr-x 0 0 0 1770 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs -r-xr-xr-x 0 0 0 37120 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/fs.cjs drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/rules_nodejs++node+nodejs_linux_amd64/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/rules_nodejs++node+nodejs_linux_amd64/bin/ diff --git a/js/private/test/image/custom_owner_test_node.listing b/js/private/test/image/custom_owner_test_node.listing index 778e0315eb..971563c937 100644 --- a/js/private/test/image/custom_owner_test_node.listing +++ b/js/private/test/image/custom_owner_test_node.listing @@ -7,7 +7,7 @@ drwxr-xr-x 0 100 0 0 Jan 1 1970 ./js/private/test/image/bin.runf drwxr-xr-x 0 100 0 0 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/ drwxr-xr-x 0 100 0 0 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/private/ drwxr-xr-x 0 100 0 0 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/ --r-xr-xr-x 0 100 0 1460 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs +-r-xr-xr-x 0 100 0 1770 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs -r-xr-xr-x 0 100 0 37120 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/fs.cjs drwxr-xr-x 0 100 0 0 Jan 1 1970 ./js/private/test/image/bin.runfiles/rules_nodejs++node+nodejs_linux_amd64/ drwxr-xr-x 0 100 0 0 Jan 1 1970 ./js/private/test/image/bin.runfiles/rules_nodejs++node+nodejs_linux_amd64/bin/ diff --git a/js/private/test/image/default_test_node.listing b/js/private/test/image/default_test_node.listing index f390d9c357..ea72b4e474 100644 --- a/js/private/test/image/default_test_node.listing +++ b/js/private/test/image/default_test_node.listing @@ -7,7 +7,7 @@ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./js/private/test/image/bin.runf drwxr-xr-x 0 0 0 0 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/private/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/ --r-xr-xr-x 0 0 0 1460 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs +-r-xr-xr-x 0 0 0 1770 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs -r-xr-xr-x 0 0 0 37120 Jan 1 1970 ./js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/fs.cjs drwxr-xr-x 0 0 0 0 Jan 1 1970 ./js/private/test/image/bin.runfiles/rules_nodejs++node+nodejs_linux_amd64/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./js/private/test/image/bin.runfiles/rules_nodejs++node+nodejs_linux_amd64/bin/ diff --git a/js/private/test/image/non_ascii/custom_layer_groups_test_node.listing b/js/private/test/image/non_ascii/custom_layer_groups_test_node.listing index ef2dbd03c1..846220a750 100644 --- a/js/private/test/image/non_ascii/custom_layer_groups_test_node.listing +++ b/js/private/test/image/non_ascii/custom_layer_groups_test_node.listing @@ -9,7 +9,7 @@ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/non_ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/non_ascii/bin2.runfiles/_main/js/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/non_ascii/bin2.runfiles/_main/js/private/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/non_ascii/bin2.runfiles/_main/js/private/node-bootstrap/ --r-xr-xr-x 0 0 0 1460 Jan 1 1970 ./app/js/private/test/image/non_ascii/bin2.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs +-r-xr-xr-x 0 0 0 1770 Jan 1 1970 ./app/js/private/test/image/non_ascii/bin2.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/non_ascii/bin2.runfiles/rules_nodejs++node+nodejs_linux_amd64/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/non_ascii/bin2.runfiles/rules_nodejs++node+nodejs_linux_amd64/bin/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/non_ascii/bin2.runfiles/rules_nodejs++node+nodejs_linux_amd64/bin/nodejs/ diff --git a/js/private/test/image/platform_deps/rspack_linux_arm64_test_node.listing b/js/private/test/image/platform_deps/rspack_linux_arm64_test_node.listing index 6837a011a6..88231d4d09 100644 --- a/js/private/test/image/platform_deps/rspack_linux_arm64_test_node.listing +++ b/js/private/test/image/platform_deps/rspack_linux_arm64_test_node.listing @@ -9,7 +9,7 @@ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/plat drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/platform_deps/bin.runfiles/_main/js/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/platform_deps/bin.runfiles/_main/js/private/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/platform_deps/bin.runfiles/_main/js/private/node-bootstrap/ --r-xr-xr-x 0 0 0 1460 Jan 1 1970 ./app/js/private/test/image/platform_deps/bin.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs +-r-xr-xr-x 0 0 0 1770 Jan 1 1970 ./app/js/private/test/image/platform_deps/bin.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs -r-xr-xr-x 0 0 0 37120 Jan 1 1970 ./app/js/private/test/image/platform_deps/bin.runfiles/_main/js/private/node-bootstrap/fs.cjs drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/platform_deps/bin.runfiles/rules_nodejs++node+nodejs_linux_arm64/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/platform_deps/bin.runfiles/rules_nodejs++node+nodejs_linux_arm64/bin/ diff --git a/js/private/test/image/regex_edge_cases_test_node.listing b/js/private/test/image/regex_edge_cases_test_node.listing index 60de743170..222653dcbc 100644 --- a/js/private/test/image/regex_edge_cases_test_node.listing +++ b/js/private/test/image/regex_edge_cases_test_node.listing @@ -8,7 +8,7 @@ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin. drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/private/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/ --r-xr-xr-x 0 0 0 1460 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs +-r-xr-xr-x 0 0 0 1770 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/bootstrap.cjs -r-xr-xr-x 0 0 0 37120 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/_main/js/private/node-bootstrap/fs.cjs drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/rules_nodejs++node+nodejs_linux_amd64/ drwxr-xr-x 0 0 0 0 Jan 1 1970 ./app/js/private/test/image/bin.runfiles/rules_nodejs++node+nodejs_linux_amd64/bin/ diff --git a/js/private/test/js_binary_sh/BUILD.bazel b/js/private/test/js_binary_sh/BUILD.bazel index 414ed7a1e0..941e1e153d 100644 --- a/js/private/test/js_binary_sh/BUILD.bazel +++ b/js/private/test/js_binary_sh/BUILD.bazel @@ -14,8 +14,16 @@ js_binary( entry_point = "one.js", ) +# The assertions below are on the launcher script's own diagnostics, including the +# per-target constants it bakes in, so this target has to run the script. Wrapping the +# empty `args` in a select() is what holds it there: the js_run_binary macro cannot see +# through one, so it declines the hermetic launcher, while the command line the select +# resolves to is still empty. Without this the target would take the hermetic launcher +# whenever --//js:use_execroot_entry_point is False, and that launcher prints none of +# this -- JS_BINARY__WORKSPACE and the rest are documented as unreachable from it. js_run_binary( name = "capture_stderr", + args = select({"//conditions:default": []}), log_level = "debug", silent_on_success = False, stderr = "stderr", diff --git a/js/private/test/node-patches/BUILD.bazel b/js/private/test/node-patches/BUILD.bazel index 69d55e7be8..b13fb8dcaf 100644 --- a/js/private/test/node-patches/BUILD.bazel +++ b/js/private/test/node-patches/BUILD.bazel @@ -67,11 +67,10 @@ babel_bin.babel( name = "babel_mjs2js", srcs = MJS_TESTS, outs = CJS_TESTS, - chdir = package_name(), - data = [":babel_config"], - fixed_args = [ - "--config-file", - "$$RUNFILES_DIR/$(rlocationpath :babel_config)", + # Only the config path needs `$RUNFILES_DIR` expanded; the rest are ordinary `args`, + # which follow `fixed_args`, so babel sees the same command line either way and the + # hermetic launcher's embedded argument budget is not spent on them. + args = [ "--extensions", ".mjs", "--out-file-extension", @@ -79,6 +78,12 @@ babel_bin.babel( "--out-dir", ".", ] + MJS_TESTS, + chdir = package_name(), + data = [":babel_config"], + fixed_args = [ + "--config-file", + "$$RUNFILES_DIR/$(rlocationpath :babel_config)", + ], silent_on_success = False, ) diff --git a/js/private/test/snapshots/launcher.sh b/js/private/test/snapshots/launcher.sh index 63b4efec30..697df7ba75 100644 --- a/js/private/test/snapshots/launcher.sh +++ b/js/private/test/snapshots/launcher.sh @@ -476,6 +476,11 @@ if [ "${JS_BINARY__NO_RUNFILES:-}" ]; then export JS_BINARY__NODE_PATCHES JS_BINARY__NODE_PATCHES=$(resolve_execroot_src_path "js/private/node-bootstrap/bootstrap.cjs") else + # Spelled as a runfiles-root-relative path rather than via + # "_main/../", so that this is byte-for-byte the path the node + # launcher binary passes to --require. A child process inherits this one in + # execArgv and gets the launcher's as well; if the two spellings differ, node + # loads the patches twice and the second application fails. export JS_BINARY__NODE_PATCHES="$JS_BINARY__RUNFILES/_main/js/private/node-bootstrap/bootstrap.cjs" fi if [ ! -f "$JS_BINARY__NODE_PATCHES" ]; then diff --git a/js/private/worker/src/BUILD.bazel b/js/private/worker/src/BUILD.bazel index 264afae218..cc13073f5c 100644 --- a/js/private/worker/src/BUILD.bazel +++ b/js/private/worker/src/BUILD.bazel @@ -27,16 +27,21 @@ rollup_bin.rollup( ":node_modules/google-protobuf", ], outs = ["bundle.js"], + # Only the config path needs `$RUNFILES_DIR` expanded; the rest are ordinary `args`, + # which follow `fixed_args`, so rollup sees the same command line either way and the + # hermetic launcher's embedded argument budget is not spent on them. + args = [ + "--format", + "cjs", + "--file", + "bundle.js", + ], chdir = package_name(), data = [":config"], fixed_args = [ "index.ts", "--config", "$$RUNFILES_DIR/$(rlocationpath :config)", - "--format", - "cjs", - "--file", - "bundle.js", ], silent_on_success = False, visibility = ["//js/private/worker:__pkg__"], diff --git a/npm/private/lifecycle/src/BUILD.bazel b/npm/private/lifecycle/src/BUILD.bazel index c9e1b52f7f..689c90db32 100644 --- a/npm/private/lifecycle/src/BUILD.bazel +++ b/npm/private/lifecycle/src/BUILD.bazel @@ -21,16 +21,21 @@ rollup_bin.rollup( "//npm/private/lifecycle:node_modules/@pnpm/read-package-json", ], outs = ["index.min.js"], + # Only the config path needs `$RUNFILES_DIR` expanded; the rest are ordinary `args`, + # which follow `fixed_args`, so rollup sees the same command line either way and the + # hermetic launcher's embedded argument budget is not spent on them. + args = [ + "--format", + "cjs", + "--file", + "index.min.js", + ], chdir = package_name(), data = ["//npm/private/lifecycle:rollup_config"], fixed_args = [ "lifecycle-hooks.js", "--config", "$$RUNFILES_DIR/$(rlocationpath //npm/private/lifecycle:rollup_config)", - "--format", - "cjs", - "--file", - "index.min.js", ], silent_on_success = False, visibility = ["//npm/private/lifecycle:__subpackages__"],