diff --git a/MODULE.bazel b/MODULE.bazel index e9ae7a86..4d11b8d7 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -10,14 +10,17 @@ module( bazel_dep(name = "bazel_features", version = "1.27.0") bazel_dep(name = "bazel_skylib", version = "1.3.0") bazel_dep(name = "platforms", version = "0.0.9") +bazel_dep(name = "rules_shell", version = "0.3.0") bazel_dep(name = "rules_cc", version = "0.2.15") apple_cc_configure = use_extension("//crosstool:setup.bzl", "apple_cc_configure_extension") use_repo(apple_cc_configure, "local_config_apple_cc", "local_config_apple_cc_toolchains") -register_toolchains("@local_config_apple_cc_toolchains//:all") +register_toolchains( + "@local_config_apple_cc_toolchains//:all", + "//rules/install_name_tool:default_toolchain", +) -bazel_dep(name = "rules_shell", version = "0.3.0", dev_dependency = True) bazel_dep(name = "stardoc", version = "0.8.0", dev_dependency = True) # TODO: Remove when transitives bump past this diff --git a/rules/install_name_tool/BUILD b/rules/install_name_tool/BUILD new file mode 100644 index 00000000..0a6fb80c --- /dev/null +++ b/rules/install_name_tool/BUILD @@ -0,0 +1,53 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") +load(":xcode_toolchain.bzl", "xcode_install_name_tool_toolchain") + +licenses(["notice"]) + +package(default_visibility = ["//visibility:public"]) + +toolchain_type(name = "toolchain_type") + +sh_binary( + name = "install_name_tool", + srcs = ["install_name_tool_wrapper.sh"], + visibility = ["//visibility:private"], +) + +xcode_install_name_tool_toolchain( + name = "install_name_tool_toolchain", + tool = ":install_name_tool", +) + +toolchain( + name = "default_toolchain", + exec_compatible_with = ["@platforms//os:macos"], + toolchain = ":install_name_tool_toolchain", + toolchain_type = ":toolchain_type", +) + +bzl_library( + name = "install_name_tool_bzl", + srcs = ["install_name_tool.bzl"], +) + +bzl_library( + name = "toolchain_bzl", + srcs = ["toolchain.bzl"], +) + +bzl_library( + name = "xcode_toolchain_bzl", + srcs = ["xcode_toolchain.bzl"], + deps = [ + ":toolchain_bzl", + "//lib:apple_support", + ], +) + +filegroup( + name = "for_bazel_tests", + testonly = 1, + srcs = glob(["**"]), + visibility = ["//:__pkg__"], +) diff --git a/rules/install_name_tool/install_name_tool.bzl b/rules/install_name_tool/install_name_tool.bzl new file mode 100644 index 00000000..a6126883 --- /dev/null +++ b/rules/install_name_tool/install_name_tool.bzl @@ -0,0 +1,115 @@ +# Copyright 2026 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Rule for modifying Mach-O binaries with install_name_tool.""" + +_TOOLCHAIN_TYPE = "//rules/install_name_tool:toolchain_type" + +def _install_name_tool_impl(ctx): + args = ctx.actions.args() + if ctx.attr.install_name: + args.add("-id", ctx.attr.install_name) + + for rpath in ctx.attr.add_rpath: + args.add("-add_rpath", rpath) + + for rpath in ctx.attr.prepend_rpath: + args.add("-prepend_rpath", rpath) + + for rpath in ctx.attr.delete_rpath: + args.add("-delete_rpath", rpath) + + for old, new in ctx.attr.change_library.items(): + args.add("-change") + args.add(old) + args.add(new) + + for old, new in ctx.attr.change_rpath.items(): + args.add("-rpath") + args.add(old) + args.add(new) + + if not args: + fail("No modifications specified for install_name_tool.") + + toolchain_info = ctx.toolchains[_TOOLCHAIN_TYPE].install_name_tool_info + output = ctx.actions.declare_file(ctx.label.name) + args.add(output) + + ctx.actions.run_shell( + inputs = [ctx.file.src], + outputs = [output], + tools = [toolchain_info.tool], + command = "cp \"$1\" \"$2\" && chmod u+w \"$2\" && shift 2 && exec \"$@\"", + arguments = [ctx.file.src.path, output.path, toolchain_info.tool.executable.path, args], + mnemonic = "InstallNameTool", + progress_message = "Editing load commands %{output}", + env = toolchain_info.env, + execution_requirements = toolchain_info.execution_requirements, + use_default_shell_env = True, + ) + + return [DefaultInfo( + files = depset([output]), + )] + +install_name_tool = rule( + doc = """\ +Modifies a Mach-O binary using `install_name_tool`. + +This rule copies the input binary and applies the requested modifications to +the copy. It uses a toolchain to resolve the `install_name_tool` binary, +allowing users to provide their own implementation if needed. + +Example usage: + +```build +load("@build_bazel_apple_support//rules/install_name_tool:install_name_tool.bzl", "install_name_tool") + +install_name_tool( + name = "patched_lib", + src = ":my_dylib", + install_name = "@rpath/libfoo.dylib", + add_rpath = ["@loader_path/../Frameworks"], +) +``` +""", + implementation = _install_name_tool_impl, + attrs = { + "src": attr.label( + mandatory = True, + allow_single_file = True, + doc = "The Mach-O binary to modify.", + ), + "install_name": attr.string( + doc = "The new install name (`-id`) for the binary.", + ), + "add_rpath": attr.string_list( + doc = "Rpaths to add (`-add_rpath`).", + ), + "prepend_rpath": attr.string_list( + doc = "Rpaths to prepend (`-prepend_rpath`).", + ), + "delete_rpath": attr.string_list( + doc = "Rpaths to delete (`-delete_rpath`).", + ), + "change_library": attr.string_dict( + doc = "Library paths to change (`-change old new`). Keys are old paths, values are new paths.", + ), + "change_rpath": attr.string_dict( + doc = "Rpaths to change (`-rpath old new`). Keys are old rpaths, values are new rpaths.", + ), + }, + toolchains = [_TOOLCHAIN_TYPE], +) diff --git a/rules/install_name_tool/install_name_tool_wrapper.sh b/rules/install_name_tool/install_name_tool_wrapper.sh new file mode 100755 index 00000000..a3139c82 --- /dev/null +++ b/rules/install_name_tool/install_name_tool_wrapper.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +exec /usr/bin/install_name_tool "$@" diff --git a/rules/install_name_tool/toolchain.bzl b/rules/install_name_tool/toolchain.bzl new file mode 100644 index 00000000..120c4951 --- /dev/null +++ b/rules/install_name_tool/toolchain.bzl @@ -0,0 +1,63 @@ +# Copyright 2026 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Toolchain rule for providing a custom `install_name_tool` tool.""" + +InstallNameToolInfo = provider( + doc = "Provides an `install_name_tool` for modifying Mach-O binaries.", + fields = { + "tool": "A `FilesToRunProvider` for the `install_name_tool` tool.", + "env": "A `dict` of environment variables to set when running the tool.", + "execution_requirements": """\ +A `dict` of execution requirements for the action (e.g. `requires-darwin`). +""", + }, +) + +def _install_name_tool_toolchain_impl(ctx): + return [ + platform_common.ToolchainInfo( + install_name_tool_info = InstallNameToolInfo( + tool = ctx.attr.tool[DefaultInfo].files_to_run, + env = ctx.attr.env, + execution_requirements = ctx.attr.execution_requirements, + ), + ), + ] + +install_name_tool_toolchain = rule( + attrs = { + "tool": attr.label( + doc = "The `install_name_tool` binary.", + mandatory = True, + allow_files = True, + executable = True, + cfg = "exec", + ), + "env": attr.string_dict( + doc = "Additional environment variables to set when running install_name_tool.", + default = {}, + ), + "execution_requirements": attr.string_dict( + doc = "Additional execution requirements for the action.", + default = {}, + ), + }, + doc = """\ +Defines a toolchain for `install_name_tool` used to modify Mach-O binaries. +Use this to provide a custom `install_name_tool` implementation by defining an +`install_name_tool_toolchain` target and registering it as a toolchain. +""", + implementation = _install_name_tool_toolchain_impl, +) diff --git a/rules/install_name_tool/xcode_toolchain.bzl b/rules/install_name_tool/xcode_toolchain.bzl new file mode 100644 index 00000000..2668f3cd --- /dev/null +++ b/rules/install_name_tool/xcode_toolchain.bzl @@ -0,0 +1,67 @@ +# Copyright 2026 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Xcode-aware toolchain rule for providing an `install_name_tool` tool.""" + +load("//lib:apple_support.bzl", "apple_support") +load(":toolchain.bzl", "InstallNameToolInfo") + +def _xcode_install_name_tool_toolchain_impl(ctx): + env = dict(ctx.attr.env) + execution_requirements = dict(ctx.attr.execution_requirements) + + xcode_config = ctx.attr._xcode_config[apple_common.XcodeVersionConfig] + if xcode_config: + env.update(apple_common.apple_host_system_env(xcode_config)) + env.update( + apple_common.target_apple_env(xcode_config, ctx.fragments.apple.single_arch_platform), + ) + execution_requirements.update(xcode_config.execution_info()) + + return [ + platform_common.ToolchainInfo( + install_name_tool_info = InstallNameToolInfo( + tool = ctx.attr.tool[DefaultInfo].files_to_run, + env = env, + execution_requirements = execution_requirements, + ), + ), + ] + +xcode_install_name_tool_toolchain = rule( + attrs = apple_support.action_required_attrs() | { + "tool": attr.label( + doc = "The `install_name_tool` binary.", + mandatory = True, + allow_files = True, + executable = True, + cfg = "exec", + ), + "env": attr.string_dict( + doc = "Additional environment variables to set when running install_name_tool.", + default = {}, + ), + "execution_requirements": attr.string_dict( + doc = "Additional execution requirements for the action.", + default = {}, + ), + }, + doc = """\ +Defines a toolchain for `install_name_tool` used to modify Mach-O binaries. +This toolchain automatically sets environment variables and execution +requirements required to run Xcode's install_name_tool hermetically. +""", + fragments = ["apple"], + implementation = _xcode_install_name_tool_toolchain_impl, +) diff --git a/test/BUILD b/test/BUILD index e8c3c493..a5ddfee5 100644 --- a/test/BUILD +++ b/test/BUILD @@ -4,6 +4,7 @@ load("@rules_cc//cc:cc_shared_library.bzl", "cc_shared_library") load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test", "objc_library") load("@rules_shell//shell:sh_test.bzl", "sh_test") load("//rules:apple_genrule.bzl", "apple_genrule") +load("//rules/install_name_tool:install_name_tool.bzl", "install_name_tool") load(":apple_support_test.bzl", "apple_support_test") load(":available_xcodes_test.bzl", "available_xcodes_test") load(":binary_tests.bzl", "binary_test_suite") @@ -97,6 +98,37 @@ universal_binary_test( target_under_test = "//test/test_data:multi_arch_cc_binary", ) +install_name_tool( + name = "patched_dylib", + testonly = True, + src = "//test/test_data:test_dylib", + add_rpath = ["CUSTOM_RPATH"], + install_name = "CUSTOM_INSTALL_NAME", +) + +sh_test( + name = "install_name_tool_test", + size = "small", + srcs = ["install_name_tool_test.sh"], + args = ["$(location :patched_dylib)"], + data = [":patched_dylib"], +) + +install_name_tool( + name = "patched_dylib_changed_rpath", + testonly = True, + src = ":patched_dylib", + change_rpath = {"CUSTOM_RPATH": "CUSTOM_CHANGED_RPATH"}, +) + +sh_test( + name = "install_name_tool_rpath_test", + size = "small", + srcs = ["install_name_tool_rpath_test.sh"], + args = ["$(location :patched_dylib_changed_rpath)"], + data = [":patched_dylib_changed_rpath"], +) + # Consumed by bazel tests. filegroup( name = "for_bazel_tests", diff --git a/test/install_name_tool_rpath_test.sh b/test/install_name_tool_rpath_test.sh new file mode 100755 index 00000000..7e05c3c6 --- /dev/null +++ b/test/install_name_tool_rpath_test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Copyright 2026 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +binary="$1" + +fail() { + echo "FAILURE: $1" >&2 + exit 1 +} + +otool_output=$(otool -l "$binary") + +if echo "$otool_output" | grep -q "CUSTOM_RPATH"; then + fail "old rpath 'CUSTOM_RPATH' should have been changed" +fi + +if ! echo "$otool_output" | grep -q "CUSTOM_CHANGED_RPATH"; then + fail "new rpath 'CUSTOM_CHANGED_RPATH' not found in binary" +fi + +echo "PASS" diff --git a/test/install_name_tool_test.sh b/test/install_name_tool_test.sh new file mode 100755 index 00000000..f781d1f7 --- /dev/null +++ b/test/install_name_tool_test.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Copyright 2026 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +binary="$1" + +fail() { + echo "FAILURE: $1" >&2 + exit 1 +} + +otool_output=$(otool -l "$binary") + +# Verify install name was changed +if ! echo "$otool_output" | grep -q "CUSTOM_INSTALL_NAME"; then + fail "install name 'CUSTOM_INSTALL_NAME' not found in binary" +fi + +# Verify rpath was added +if ! echo "$otool_output" | grep -q "CUSTOM_RPATH"; then + fail "rpath 'CUSTOM_RPATH' not found in binary" +fi + +echo "PASS" diff --git a/test/test_data/BUILD b/test/test_data/BUILD index 57ab4a4d..742d71e7 100644 --- a/test/test_data/BUILD +++ b/test/test_data/BUILD @@ -216,6 +216,13 @@ objc_library( tags = TARGETS_UNDER_TEST_TAGS, ) +cc_binary( + name = "test_dylib", + srcs = ["dylib.c"], + linkopts = ["-dynamiclib"], + tags = TARGETS_UNDER_TEST_TAGS, +) + cc_library( name = "test_lib_for_coverage", srcs = ["test_lib.c"], diff --git a/test/test_data/dylib.c b/test/test_data/dylib.c new file mode 100644 index 00000000..18e3f597 --- /dev/null +++ b/test/test_data/dylib.c @@ -0,0 +1 @@ +int dylib_func(void) { return 42; }