From ee91a13d46e372194521e8e37f265dff977e4c1d Mon Sep 17 00:00:00 2001 From: Karim Alweheshy Date: Sat, 1 Aug 2026 01:54:23 +0200 Subject: [PATCH 1/2] feat: derive private framework exports from clients --- apple/BUILD | 1 + apple/internal/BUILD | 13 + apple/internal/exported_symbols_list.bzl | 230 ++++++++++++++++++ apple/linker.bzl | 5 + doc/rules-linker.md | 55 +++++ test/starlark_tests/BUILD | 3 + .../exported_symbols_list_tests.bzl | 94 +++++++ .../targets_under_test/ios/BUILD | 101 ++++++++ tools/BUILD | 1 + tools/exported_symbols_list/BUILD | 33 +++ .../exported_symbols_list.py | 200 +++++++++++++++ .../exported_symbols_list_test.py | 118 +++++++++ 12 files changed, 854 insertions(+) create mode 100644 apple/internal/exported_symbols_list.bzl create mode 100644 test/starlark_tests/exported_symbols_list_tests.bzl create mode 100644 tools/exported_symbols_list/BUILD create mode 100644 tools/exported_symbols_list/exported_symbols_list.py create mode 100644 tools/exported_symbols_list/exported_symbols_list_test.py diff --git a/apple/BUILD b/apple/BUILD index cdecb419b4..4821231005 100644 --- a/apple/BUILD +++ b/apple/BUILD @@ -154,6 +154,7 @@ bzl_library( name = "linker", srcs = ["linker.bzl"], deps = [ + "//apple/internal:exported_symbols_list", "//apple/internal:order_file", ], ) diff --git a/apple/internal/BUILD b/apple/internal/BUILD index 0b8e0484f2..6e894fe2df 100644 --- a/apple/internal/BUILD +++ b/apple/internal/BUILD @@ -429,6 +429,19 @@ bzl_library( ], ) +bzl_library( + name = "exported_symbols_list", + srcs = ["exported_symbols_list.bzl"], + visibility = [ + "//apple:__subpackages__", + ], + deps = [ + "@apple_support//lib:apple_support", + "@bazel_skylib//lib:dicts", + "@rules_cc//cc/common", + ], +) + bzl_library( name = "order_file", srcs = ["order_file.bzl"], diff --git a/apple/internal/exported_symbols_list.bzl b/apple/internal/exported_symbols_list.bzl new file mode 100644 index 0000000000..a7de93cd6b --- /dev/null +++ b/apple/internal/exported_symbols_list.bzl @@ -0,0 +1,230 @@ +# 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. + +"""Generates an exported-symbol list for a closed-world Apple framework.""" + +load( + "@apple_support//lib:apple_support.bzl", + "apple_support", +) +load("@bazel_skylib//lib:dicts.bzl", "dicts") +load("@rules_cc//cc/common:cc_info.bzl", "CcInfo") + +visibility([ + "//apple/...", + "//test/...", +]) + +def _transitive_link_files(targets): + """Returns static link inputs reachable through the targets' CcInfo.""" + files = {} + for target in targets: + # The providers constraint on the attributes makes this defensive + # branch relevant only to unusual forwarding rules. + if CcInfo not in target: + continue + + # linking_context is already transitive, so this follows deps, + # implementation_deps, and rule-specific forwarding without needing + # to know which attributes each language rule uses. + for linker_input in target[CcInfo].linking_context.linker_inputs.to_list(): + for library in linker_input.libraries: + if library.static_library: + files[library.static_library.path] = library.static_library + continue + + if library.pic_static_library: + files[library.pic_static_library.path] = library.pic_static_library + continue + + for obj in library.objects: + files[obj.path] = obj + for obj in library.pic_objects: + files[obj.path] = obj + return files + +def _sorted_files(files_by_path): + return [files_by_path[path] for path in sorted(files_by_path.keys())] + +def _exported_symbols_list_impl(ctx): + framework_files = _transitive_link_files(ctx.attr.deps) + if not framework_files: + fail("deps must provide at least one static link input through CcInfo") + + client_files = _transitive_link_files(ctx.attr.clients) + + # A client CcInfo graph normally includes the framework libraries it uses. + # Remove those inputs so references internal to the framework do not look + # like client imports and unnecessarily widen the exported ABI. + for framework_path in framework_files: + client_files.pop(framework_path, None) + + if not client_files: + fail("clients must provide at least one link input outside deps") + + framework_inputs = _sorted_files(framework_files) + client_inputs = _sorted_files(client_files) + framework_manifest = ctx.actions.declare_file( + ctx.label.name + ".framework-inputs", + ) + client_manifest = ctx.actions.declare_file( + ctx.label.name + ".client-inputs", + ) + exported_symbols = ctx.actions.declare_file( + ctx.label.name + ".exported_symbols", + ) + report = ctx.actions.declare_file(ctx.label.name + ".report.json") + + # Manifests keep the command line short and make analysis deterministic. + ctx.actions.write( + framework_manifest, + "\n".join([file.path for file in framework_inputs]) + "\n", + ) + ctx.actions.write( + client_manifest, + "\n".join([file.path for file in client_inputs]) + "\n", + ) + + args = ctx.actions.args() + args.add("--framework-inputs", framework_manifest) + args.add("--client-inputs", client_manifest) + for additional_list in ctx.files.additional_exported_symbols_lists: + args.add("--additional-exported-symbols", additional_list) + if ctx.attr.preserve_all_non_swift_exports: + args.add("--preserve-all-non-swift-exports") + if ctx.executable.nm: + args.add("--nm", ctx.executable.nm) + args.add("--output", exported_symbols) + args.add("--report", report) + + direct_inputs = [framework_manifest, client_manifest] + direct_inputs.extend(ctx.files.additional_exported_symbols_lists) + tools = [] + if ctx.executable.nm: + tools.append(ctx.executable.nm) + + apple_support.run( + actions = ctx.actions, + apple_fragment = ctx.fragments.apple, + arguments = [args], + executable = ctx.executable._generator, + inputs = depset( + direct = direct_inputs, + transitive = [ + depset(framework_inputs), + depset(client_inputs), + ], + ), + mnemonic = "AppleExportedSymbolsList", + outputs = [exported_symbols, report], + progress_message = "Deriving client-required exports for {}".format(ctx.label), + tools = tools, + xcode_config = ctx.attr._xcode_config[apple_common.XcodeVersionConfig], + ) + + return [ + DefaultInfo(files = depset([exported_symbols])), + # The report is useful for audits, but it is not a linker input and + # should not be downloaded by every consumer of the symbol list. + OutputGroupInfo(report = depset([report])), + ] + +exported_symbols_list = rule( + implementation = _exported_symbols_list_impl, + attrs = dicts.add(apple_support.action_required_attrs(), { + "additional_exported_symbols_lists": attr.label_list( + allow_files = True, + doc = """\ +Optional authored symbol lists for runtime-discovered entry points that do not +appear as undefined symbols in `clients`. +""", + ), + "clients": attr.label_list( + allow_empty = False, + doc = """\ +The complete set of application and extension link roots that may load the +framework. Pass library or binary targets that provide `CcInfo`, not bundle +targets that embed the framework, to avoid a dependency cycle. +""", + mandatory = True, + providers = [CcInfo], + ), + "deps": attr.label_list( + allow_empty = False, + doc = """\ +The same library roots linked into the private framework. Their transitive +static definitions are the maximum possible export surface. +""", + mandatory = True, + providers = [CcInfo], + ), + "nm": attr.label( + cfg = "exec", + doc = """\ +Optional `llvm-nm` executable. By default the selected Xcode's `llvm-nm` is +used through `xcrun`. Override this when the link inputs come from another LLVM +toolchain, especially when they contain LLVM bitcode. +""", + executable = True, + ), + "preserve_all_non_swift_exports": attr.bool( + default = True, + doc = """\ +Whether to retain every non-Swift definition conservatively for Objective-C +runtime and `dlsym` lookup. This can pull otherwise-unreferenced archive +members into the framework. Set this to `False` only when all runtime-discovered +entry points are absent or listed in `additional_exported_symbols_lists`. +""", + ), + "_generator": attr.label( + cfg = "exec", + default = "//tools/exported_symbols_list", + executable = True, + ), + }), + doc = """\ +Derives the exported-symbol list for a closed-world, app-private framework. + +The rule keeps definitions referenced by the declared client link graphs. By +default it also keeps every non-Swift definition because Objective-C runtime +lookup and `dlsym` do not necessarily leave static undefined references. That +conservative policy can pull otherwise-unreferenced archive members into the +framework; set `preserve_all_non_swift_exports = False` only after auditing +runtime lookup and listing its roots in `additional_exported_symbols_lists`. + +```starlark +apple_exported_symbols_list( + name = "private_framework_exports", + deps = [":private_framework_lib"], + clients = [ + ":app_binary_lib", + ":extension_binary_lib", + ], +) + +ios_framework( + name = "PrivateFramework", + deps = [":private_framework_lib"], + exported_symbols_lists = [":private_framework_exports"], + ... +) +``` + +Bazel cannot discover reverse dependencies, so `clients` must name the complete +set explicitly. Do not use a client-derived list for a public framework: its +current consumers are not a stable public ABI contract. +""", + exec_compatible_with = ["@platforms//os:macos"], + fragments = ["apple"], +) diff --git a/apple/linker.bzl b/apple/linker.bzl index c8bf9ba1d8..1080d5ad78 100644 --- a/apple/linker.bzl +++ b/apple/linker.bzl @@ -14,6 +14,10 @@ """Rules related to Apple linker.""" +load( + "//apple/internal:exported_symbols_list.bzl", + _apple_exported_symbols_list = "exported_symbols_list", +) load( "//apple/internal:order_file.bzl", _apple_order_file = "order_file", @@ -21,4 +25,5 @@ load( visibility("public") +apple_exported_symbols_list = _apple_exported_symbols_list apple_order_file = _apple_order_file diff --git a/doc/rules-linker.md b/doc/rules-linker.md index e43f41b170..a4707b5200 100755 --- a/doc/rules-linker.md +++ b/doc/rules-linker.md @@ -2,6 +2,61 @@ Rules related to Apple linker. + + +## apple_exported_symbols_list + +
+load("@rules_apple//apple:linker.bzl", "apple_exported_symbols_list")
+
+apple_exported_symbols_list(name, deps, additional_exported_symbols_lists, clients, nm,
+                            preserve_all_non_swift_exports)
+
+ +Derives the exported-symbol list for a closed-world, app-private framework. + +The rule keeps definitions referenced by the declared client link graphs. By +default it also keeps every non-Swift definition because Objective-C runtime +lookup and `dlsym` do not necessarily leave static undefined references. That +conservative policy can pull otherwise-unreferenced archive members into the +framework; set `preserve_all_non_swift_exports = False` only after auditing +runtime lookup and listing its roots in `additional_exported_symbols_lists`. + +```starlark +apple_exported_symbols_list( + name = "private_framework_exports", + deps = [":private_framework_lib"], + clients = [ + ":app_binary_lib", + ":extension_binary_lib", + ], +) + +ios_framework( + name = "PrivateFramework", + deps = [":private_framework_lib"], + exported_symbols_lists = [":private_framework_exports"], + ... +) +``` + +Bazel cannot discover reverse dependencies, so `clients` must name the complete +set explicitly. Do not use a client-derived list for a public framework: its +current consumers are not a stable public ABI contract. + +**ATTRIBUTES** + + +| Name | Description | Type | Mandatory | Default | +| :------------- | :------------- | :------------- | :------------- | :------------- | +| name | A unique name for this target. | Name | required | | +| deps | The same library roots linked into the private framework. Their transitive static definitions are the maximum possible export surface. | List of labels | required | | +| additional_exported_symbols_lists | Optional authored symbol lists for runtime-discovered entry points that do not appear as undefined symbols in `clients`. | List of labels | optional | `[]` | +| clients | The complete set of application and extension link roots that may load the framework. Pass library or binary targets that provide `CcInfo`, not bundle targets that embed the framework, to avoid a dependency cycle. | List of labels | required | | +| nm | Optional `llvm-nm` executable. By default the selected Xcode's `llvm-nm` is used through `xcrun`. Override this when the link inputs come from another LLVM toolchain, especially when they contain LLVM bitcode. | Label | optional | `None` | +| preserve_all_non_swift_exports | Whether to retain every non-Swift definition conservatively for Objective-C runtime and `dlsym` lookup. This can pull otherwise-unreferenced archive members into the framework. Set this to `False` only when all runtime-discovered entry points are absent or listed in `additional_exported_symbols_lists`. | Boolean | optional | `True` | + + ## apple_order_file diff --git a/test/starlark_tests/BUILD b/test/starlark_tests/BUILD index c9185a3d80..cd113396a6 100644 --- a/test/starlark_tests/BUILD +++ b/test/starlark_tests/BUILD @@ -15,6 +15,7 @@ load(":apple_xcframework_import_tests.bzl", "apple_xcframework_import_test_suite load(":apple_xcframework_tests.bzl", "apple_xcframework_test_suite") load(":docc_tests.bzl", "docc_test_suite") load(":dtrace_compile_tests.bzl", "dtrace_compile_test_suite") +load(":exported_symbols_list_tests.bzl", "exported_symbols_list_test_suite") load(":generate_dynamic_xcframework_tests.bzl", "generate_dynamic_xcframework_test_suite") load(":generate_import_framework_tests.bzl", "generate_import_framework_test_suite") load(":ios_app_clip_tests.bzl", "ios_app_clip_test_suite") @@ -106,6 +107,8 @@ apple_xcframework_import_test_suite(name = "apple_xcframework_import") dtrace_compile_test_suite(name = "dtrace_compile") +exported_symbols_list_test_suite(name = "exported_symbols_list") + generate_dynamic_xcframework_test_suite(name = "generate_dynamic_xcframework") generate_import_framework_test_suite(name = "generate_import_framework") diff --git a/test/starlark_tests/exported_symbols_list_tests.bzl b/test/starlark_tests/exported_symbols_list_tests.bzl new file mode 100644 index 0000000000..ca919ebd93 --- /dev/null +++ b/test/starlark_tests/exported_symbols_list_tests.bzl @@ -0,0 +1,94 @@ +# 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. + +"""Tests for client-derived framework exported-symbol lists.""" + +load( + "//test/starlark_tests/rules:action_inputs_test.bzl", + "action_inputs_test", +) +load( + "//test/starlark_tests/rules:analysis_target_outputs_test.bzl", + "analysis_target_outputs_test", +) +load( + "//test/starlark_tests/rules:common_verification_tests.bzl", + "archive_contents_test", +) + +def exported_symbols_list_test_suite(name): + """Test suite for apple_exported_symbols_list. + + Args: + name: The base name to use for tests created by this macro. + """ + target = "//test/starlark_tests/targets_under_test/ios:client_export_framework_exports" + + analysis_target_outputs_test( + name = "{}_output_test".format(name), + target_under_test = target, + expected_outputs = ["client_export_framework_exports.exported_symbols"], + tags = [name], + ) + + action_inputs_test( + name = "{}_graph_inputs_test".format(name), + target_under_test = target, + expected_inputs = [ + "client_export_client_lib", + "client_export_framework_lib", + "client_export_runtime_lib", + ], + mnemonic = "AppleExportedSymbolsList", + tags = [name], + ) + + archive_contents_test( + name = "{}_unrestricted_control_test".format(name), + target_under_test = "//test/starlark_tests/targets_under_test/ios:client_export_framework_unrestricted", + binary_contains_symbols = [ + "_$s21ClientExportFramework06unusedcB0SiyF", + "_$s21ClientExportFramework08retainedcB0SiyF", + ], + binary_test_architecture = "x86_64", + binary_test_file = "$BUNDLE_ROOT/client_export_framework_unrestricted", + build_type = "simulator", + compilation_mode = "opt", + tags = [name], + ) + + # This final-product test verifies the framework link, not just the + # intermediate list: the used Swift API is exported and the unused public + # Swift API is hidden from the Mach-O symbol/export metadata. + archive_contents_test( + name = "{}_framework_exports_test".format(name), + target_under_test = "//test/starlark_tests/targets_under_test/ios:client_export_framework", + binary_contains_symbols = [ + "_$s21ClientExportFramework08retainedcB0SiyF", + "_runtimeDiscoveredEntry", + ], + binary_not_contains_symbols = [ + "_$s21ClientExportFramework06unusedcB0SiyF", + ], + binary_test_architecture = "x86_64", + binary_test_file = "$BUNDLE_ROOT/client_export_framework", + build_type = "simulator", + compilation_mode = "opt", + tags = [name], + ) + + native.test_suite( + name = name, + tags = [name], + ) diff --git a/test/starlark_tests/targets_under_test/ios/BUILD b/test/starlark_tests/targets_under_test/ios/BUILD index e11ab7f5ca..d365f55218 100644 --- a/test/starlark_tests/targets_under_test/ios/BUILD +++ b/test/starlark_tests/targets_under_test/ios/BUILD @@ -33,6 +33,7 @@ load( ) load( "//apple:linker.bzl", + "apple_exported_symbols_list", "apple_order_file", ) load( @@ -78,6 +79,106 @@ package( default_visibility = ["//test/starlark_tests:__subpackages__"], ) +# A private framework fixture with one Swift API used by a declared client and +# one API that is public in Swift but not part of the app's runtime ABI. +write_file( + name = "client_export_framework_src", + out = "ClientExportFramework.swift", + content = [ + "@inline(never) public func retainedFrameworkExport() -> Int { 42 }", + "@inline(never) public func unusedFrameworkExport() -> Int { 7 }", + ], + tags = common.fixture_tags, +) + +swift_library( + name = "client_export_framework_lib", + srcs = [":client_export_framework_src"], + module_name = "ClientExportFramework", + tags = common.fixture_tags, +) + +# This C entry point has no static client reference. It represents conservative +# runtime discovery through Objective-C or dlsym and must remain exported. +write_file( + name = "client_export_runtime_src", + out = "ClientExportRuntime.m", + content = ["int runtimeDiscoveredEntry(void) { return 1; }"], + tags = common.fixture_tags, +) + +objc_library( + name = "client_export_runtime_lib", + srcs = [":client_export_runtime_src"], + tags = common.fixture_tags, +) + +write_file( + name = "client_export_client_src", + out = "ClientExportClient.swift", + content = [ + "import ClientExportFramework", + "@inline(never) public func usePrivateFramework() -> Int {", + " retainedFrameworkExport()", + "}", + ], + tags = common.fixture_tags, +) + +swift_library( + name = "client_export_client_lib", + srcs = [":client_export_client_src"], + module_name = "ClientExportClient", + tags = common.fixture_tags, + deps = [":client_export_framework_lib"], +) + +apple_exported_symbols_list( + name = "client_export_framework_exports", + clients = [":client_export_client_lib"], + tags = common.fixture_tags, + deps = [ + ":client_export_framework_lib", + ":client_export_runtime_lib", + ], +) + +ios_framework( + name = "client_export_framework", + bundle_id = "com.google.example.client-export-framework", + exported_symbols_lists = [":client_export_framework_exports"], + families = ["iphone"], + infoplists = [ + "//test/starlark_tests/resources:Info.plist", + ], + linkopts = ["-x"], + minimum_os_version = common.min_os_ios.baseline, + tags = common.fixture_tags, + deps = [ + ":client_export_framework_lib", + ":client_export_runtime_lib", + ], +) + +# Same inputs as client_export_framework, without a generated export policy. +# This is the negative control proving the unused public Swift API would +# otherwise remain externally visible. +ios_framework( + name = "client_export_framework_unrestricted", + bundle_id = "com.google.example.client-export-framework-unrestricted", + families = ["iphone"], + infoplists = [ + "//test/starlark_tests/resources:Info.plist", + ], + linkopts = ["-x"], + minimum_os_version = common.min_os_ios.baseline, + tags = common.fixture_tags, + deps = [ + ":client_export_framework_lib", + ":client_export_runtime_lib", + ], +) + # This is a list of iOS targets to be used for Starlark unit tests. These may not build correctly # since most of them do not have any source dependencies, so they are all tagged as manual and # notap to avoid them being built/tested when testing the //test/... diff --git a/tools/BUILD b/tools/BUILD index d1af843d58..2e54243285 100644 --- a/tools/BUILD +++ b/tools/BUILD @@ -12,6 +12,7 @@ filegroup( "//tools/codesigningtool:for_bazel_tests", "//tools/dossier_codesigningtool:for_bazel_tests", "//tools/environment_plist:for_bazel_tests", + "//tools/exported_symbols_list:for_bazel_tests", "//tools/imported_dynamic_framework_processor:for_bazel_tests", "//tools/json_tool:for_bazel_tests", "//tools/main_thread_checker_tool:for_bazel_tests", diff --git a/tools/exported_symbols_list/BUILD b/tools/exported_symbols_list/BUILD new file mode 100644 index 0000000000..62f415719a --- /dev/null +++ b/tools/exported_symbols_list/BUILD @@ -0,0 +1,33 @@ +load("@rules_python//python:py_binary.bzl", "py_binary") +load("@rules_python//python:py_library.bzl", "py_library") +load("@rules_python//python:py_test.bzl", "py_test") + +licenses(["notice"]) + +py_binary( + name = "exported_symbols_list", + srcs = ["exported_symbols_list.py"], + python_version = "PY3", + srcs_version = "PY3", + visibility = ["//apple/internal:__pkg__"], +) + +py_library( + name = "exported_symbols_list_lib", + srcs = ["exported_symbols_list.py"], + srcs_version = "PY3", +) + +py_test( + name = "exported_symbols_list_test", + srcs = ["exported_symbols_list_test.py"], + python_version = "PY3", + deps = [":exported_symbols_list_lib"], +) + +filegroup( + name = "for_bazel_tests", + testonly = True, + srcs = glob(["**"]), + visibility = ["//tools:__pkg__"], +) diff --git a/tools/exported_symbols_list/exported_symbols_list.py b/tools/exported_symbols_list/exported_symbols_list.py new file mode 100644 index 0000000000..268acc46da --- /dev/null +++ b/tools/exported_symbols_list/exported_symbols_list.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 + +# 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. + +"""Builds an exported-symbol list for a closed-world Apple framework. + +The framework is assembled from static libraries, so every public definition +would normally be eligible for the Mach-O export trie and symbol table. This +tool keeps Swift definitions imported by declared clients, plus conservative +roots for symbols whose runtime uses cannot be proven from static references. +""" + +import argparse +import json +import subprocess +from pathlib import Path + +_NM_BATCH_SIZE = 128 +_SWIFT_SYMBOL_PREFIX = "_$s" + + +def parse_nm_output(output: str) -> set[str]: + """Returns externally visible Mach-O symbols from Darwin-format llvm-nm.""" + result = set() + for line in output.splitlines(): + if " private external " in line or " external " not in line: + continue + + # The symbol is always the final token. Native Swift objects may insert + # annotations such as `[no dead strip]` after `external`. + symbol = line.split()[-1] + # Mach-O external names have a leading underscore. This also filters + # archive headings and LLVM names that are not linkable symbols. + if symbol.startswith("_"): + result.add(symbol) + return result + + +def _manifest_inputs(input_manifest: Path) -> list[str]: + return [ + line + for line in input_manifest.read_text(encoding="utf8").splitlines() + if line + ] + + +def symbols( + nm_command: list[str], input_manifest: Path, *, defined: bool +) -> set[str]: + """Collects defined or undefined externals from static link inputs.""" + inputs = _manifest_inputs(input_manifest) + result = set() + mode = "--defined-only" if defined else "--undefined-only" + # Batch inputs to stay below the platform command-line length limit. + for start in range(0, len(inputs), _NM_BATCH_SIZE): + command = [ + *nm_command, + "--extern-only", + "--format=darwin", + mode, + *inputs[start : start + _NM_BATCH_SIZE], + ] + try: + nm = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as error: + raise RuntimeError( + "llvm-nm failed for framework export analysis:\n" + f"{error.stderr}" + ) from error + result.update(parse_nm_output(nm.stdout)) + return result + + +def read_additional_exports(paths: list[Path]) -> set[str]: + """Reads authored runtime roots, ignoring blank and comment lines.""" + result = set() + for path in paths: + for line in path.read_text(encoding="utf8").splitlines(): + symbol = line.strip() + if symbol and not symbol.startswith("#"): + result.add(symbol) + return result + + +def select_exports( + defined_exports: set[str], + client_imports: set[str], + additional_exports: set[str], + *, + preserve_all_non_swift_exports: bool, +) -> tuple[set[str], set[str], set[str]]: + """Applies the closed-world framework export policy. + + Returns the statically referenced exports, the conservatively retained + non-Swift exports, and their union with authored runtime roots. + """ + statically_used = defined_exports & client_imports + non_swift_exports = set() + if preserve_all_non_swift_exports: + non_swift_exports = { + symbol + for symbol in defined_exports + if not symbol.startswith(_SWIFT_SYMBOL_PREFIX) + } + return ( + statically_used, + non_swift_exports, + statically_used | non_swift_exports | additional_exports, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--framework-inputs", required=True, type=Path) + parser.add_argument("--client-inputs", required=True, type=Path) + parser.add_argument( + "--additional-exported-symbols", + action="append", + default=[], + type=Path, + ) + parser.add_argument("--nm", type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument( + "--preserve-all-non-swift-exports", + action="store_true", + ) + parser.add_argument("--report", required=True, type=Path) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + nm_command = [str(args.nm)] if args.nm else ["/usr/bin/xcrun", "llvm-nm"] + + # Framework definitions are the maximum possible export surface. Undefined + # references from client-only inputs prove which Swift symbols must cross + # the framework boundary at runtime. + defined_exports = symbols(nm_command, args.framework_inputs, defined=True) + client_imports = symbols(nm_command, args.client_inputs, defined=False) + additional_exports = read_additional_exports(args.additional_exported_symbols) + statically_used, non_swift_exports, selected_exports = select_exports( + defined_exports, + client_imports, + additional_exports, + preserve_all_non_swift_exports=args.preserve_all_non_swift_exports, + ) + allowlist = sorted(selected_exports) + + # Sorting makes the output deterministic. The report exposes each policy + # bucket so adopters can audit why symbols were retained. + output_text = "\n".join(allowlist) + args.output.write_text( + output_text + ("\n" if output_text else ""), + encoding="utf8", + ) + args.report.write_text( + json.dumps( + { + "additional_exports": len(additional_exports), + "allowlist_exports": len(allowlist), + "client_imports": len(client_imports), + "client_inputs": len(_manifest_inputs(args.client_inputs)), + "defined_exports": len(defined_exports), + "framework_inputs": len( + _manifest_inputs(args.framework_inputs) + ), + "non_swift_exports": len(non_swift_exports), + "preserve_all_non_swift_exports": ( + args.preserve_all_non_swift_exports + ), + "statically_used_exports": len(statically_used), + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf8", + ) + + +if __name__ == "__main__": + main() diff --git a/tools/exported_symbols_list/exported_symbols_list_test.py b/tools/exported_symbols_list/exported_symbols_list_test.py new file mode 100644 index 0000000000..4844e72ba4 --- /dev/null +++ b/tools/exported_symbols_list/exported_symbols_list_test.py @@ -0,0 +1,118 @@ +# 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. + +import tempfile +import unittest +from pathlib import Path + +from tools.exported_symbols_list.exported_symbols_list import ( + parse_nm_output, + read_additional_exports, + select_exports, +) + + +class ExportedSymbolsListTest(unittest.TestCase): + def test_parse_nm_output_keeps_only_exportable_external_symbols(self) -> None: + output = """ +_Source.swift.bc: +---------------- (LTO,CODE) weak private external _$s4Test6hiddenyyF +---------------- (LTO,CODE) external [no dead strip] _$s4Test6publicyyF +0000000000000000 (__TEXT,__text) weak external _objc_symbol + (undefined) external _$s4Test6clientyyF +""" + + self.assertEqual( + parse_nm_output(output), + { + "_$s4Test6clientyyF", + "_$s4Test6publicyyF", + "_objc_symbol", + }, + ) + + def test_parse_nm_output_ignores_headings_and_non_macho_names(self) -> None: + output = """ +_AncestorHashSlots.swift.bc: +archive.a(member.o): +---------------- (LTO,CODE) external symbol_without_macho_prefix +""" + + self.assertEqual(parse_nm_output(output), set()) + + def test_select_exports_keeps_client_swift_and_all_non_swift(self) -> None: + defined_exports = { + "_$s4Test4usedyyF", + "_$s4Test6unusedyyF", + "_OBJC_CLASS_$_RuntimeDiscoveredType", + "_c_entry_point", + } + client_imports = { + "_$s4Test4usedyyF", + "_unrelated_system_import", + } + + statically_used, non_swift_exports, allowlist = select_exports( + defined_exports, + client_imports, + {"_$s4Test12dynamicSwiftyyF"}, + preserve_all_non_swift_exports=True, + ) + + self.assertEqual(statically_used, {"_$s4Test4usedyyF"}) + self.assertEqual( + non_swift_exports, + { + "_OBJC_CLASS_$_RuntimeDiscoveredType", + "_c_entry_point", + }, + ) + self.assertEqual( + allowlist, + { + "_$s4Test4usedyyF", + "_$s4Test12dynamicSwiftyyF", + "_OBJC_CLASS_$_RuntimeDiscoveredType", + "_c_entry_point", + }, + ) + + def test_select_exports_can_omit_unreferenced_non_swift_symbols(self) -> None: + statically_used, non_swift_exports, allowlist = select_exports( + {"_$s4Test4usedyyF", "_runtime_discovered"}, + {"_$s4Test4usedyyF"}, + set(), + preserve_all_non_swift_exports=False, + ) + + self.assertEqual(statically_used, {"_$s4Test4usedyyF"}) + self.assertEqual(non_swift_exports, set()) + self.assertEqual(allowlist, {"_$s4Test4usedyyF"}) + + def test_read_additional_exports_ignores_comments_and_blank_lines(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "Additional.exp" + path.write_text( + "# Runtime lookup roots\n_$s4Test7dynamicyyF\n\n", + encoding="utf8", + ) + + self.assertEqual( + read_additional_exports([path]), + {"_$s4Test7dynamicyyF"}, + ) + + +if __name__ == "__main__": + unittest.main() From 4ac300bada995148a626e9a6adb7c0f7ec73aef5 Mon Sep 17 00:00:00 2001 From: Karim Alweheshy Date: Wed, 12 Aug 2026 14:28:53 +0200 Subject: [PATCH 2/2] fix: derive exported symbols per architecture --- apple/internal/exported_symbols_list.bzl | 7 ++- apple/internal/ios_rules.bzl | 14 ++--- apple/internal/linking_support.bzl | 37 +++++++---- apple/internal/macos_rules.bzl | 22 +++---- apple/internal/rule_attrs.bzl | 1 + .../testing/apple_test_bundle_support.bzl | 2 +- apple/internal/tvos_rules.bzl | 8 +-- apple/internal/visionos_rules.bzl | 8 +-- apple/internal/watchos_rules.bzl | 8 +-- apple/internal/xcframework_rules.bzl | 2 +- doc/rules-linker.md | 5 +- .../exported_symbols_list_tests.bzl | 25 ++++++++ .../targets_under_test/ios/BUILD | 9 ++- .../exported_symbols_list.py | 62 +++++++++++++++---- .../exported_symbols_list_test.py | 46 +++++++++++++- 15 files changed, 195 insertions(+), 61 deletions(-) diff --git a/apple/internal/exported_symbols_list.bzl b/apple/internal/exported_symbols_list.bzl index a7de93cd6b..4019971b93 100644 --- a/apple/internal/exported_symbols_list.bzl +++ b/apple/internal/exported_symbols_list.bzl @@ -147,7 +147,9 @@ exported_symbols_list = rule( allow_files = True, doc = """\ Optional authored symbol lists for runtime-discovered entry points that do not -appear as undefined symbols in `clients`. +appear as undefined symbols in `clients`. Entries are retained only when that +architecture's framework inputs define them; missing entries are listed in the +`report` output group on this target. """, ), "clients": attr.label_list( @@ -202,6 +204,9 @@ lookup and `dlsym` do not necessarily leave static undefined references. That conservative policy can pull otherwise-unreferenced archive members into the framework; set `preserve_all_non_swift_exports = False` only after auditing runtime lookup and listing its roots in `additional_exported_symbols_lists`. +Authored roots are validated independently for each architecture, and the +`report` output group on this target lists selected policy buckets and missing +entries. ```starlark apple_exported_symbols_list( diff --git a/apple/internal/ios_rules.bzl b/apple/internal/ios_rules.bzl index 7d2f703c21..91ecefd054 100644 --- a/apple/internal/ios_rules.bzl +++ b/apple/internal/ios_rules.bzl @@ -259,7 +259,7 @@ def _ios_application_impl(ctx): cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -643,7 +643,7 @@ def _ios_app_clip_impl(ctx): cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, stamp = ctx.attr.stamp, @@ -984,7 +984,7 @@ def _ios_framework_impl(ctx): avoid_deps = ctx.attr.frameworks, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -1262,7 +1262,7 @@ def _ios_extension_impl(ctx): cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -1580,7 +1580,7 @@ def _ios_dynamic_framework_impl(ctx): avoid_deps = ctx.attr.frameworks, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -2220,7 +2220,7 @@ def _ios_imessage_extension_impl(ctx): cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -2687,7 +2687,7 @@ def _ios_kernel_extension_impl(ctx): ctx, cc_toolchains = cc_toolchain_forwarder, entitlements = entitlements.linking if ctx.file.entitlements else None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, extra_requested_features = ["kernel_extension"], platform_prerequisites = platform_prerequisites, diff --git a/apple/internal/linking_support.bzl b/apple/internal/linking_support.bzl index 9f7436837e..942865a802 100644 --- a/apple/internal/linking_support.bzl +++ b/apple/internal/linking_support.bzl @@ -146,6 +146,7 @@ def _link_multi_arch_binary( ctx, avoid_deps, cc_toolchains, + exported_symbols_lists, extra_linkopts, extra_link_inputs, extra_requested_features, @@ -169,6 +170,8 @@ def _link_multi_arch_binary( this binary. cc_toolchains: Dictionary of CcToolchainInfo and ApplePlatformInfo providers under a split transition to relay target platform information for related deps. + exported_symbols_lists: Dictionary of exported-symbol-list targets under the same split + transition as `deps`. extra_linkopts: A list of strings: Extra linkopts to add to the linking action. extra_link_inputs: A list of strings: Extra files to pass to the linker action. extra_requested_features: A list of strings: Extra requested features to be passed @@ -196,13 +199,21 @@ def _link_multi_arch_binary( split_deps = ctx.split_attr.deps - if split_deps and split_deps.keys() != cc_toolchains.keys(): + if split_deps and sorted(split_deps.keys()) != sorted(cc_toolchains.keys()): fail(("Split transition keys are different between 'deps' [%s] and " + "'_cc_toolchain_forwarder' [%s]") % ( split_deps.keys(), cc_toolchains.keys(), )) + if (exported_symbols_lists and + sorted(exported_symbols_lists.keys()) != sorted(cc_toolchains.keys())): + fail(("Split transition keys are different between 'exported_symbols_lists' [%s] and " + + "'_cc_toolchain_forwarder' [%s]") % ( + exported_symbols_lists.keys(), + cc_toolchains.keys(), + )) + avoid_cc_infos = [ dep[AppleDynamicFrameworkInfo].cc_info for dep in avoid_deps @@ -234,6 +245,14 @@ def _link_multi_arch_binary( cc_toolchain = child_toolchain[cc_common.CcToolchainInfo] deps = split_deps.get(split_transition_key, []) platform_info = child_toolchain[ApplePlatformInfo] + split_extra_linkopts = list(extra_linkopts) + split_extra_link_inputs = list(extra_link_inputs) + for exported_symbols_list_target in exported_symbols_lists.get(split_transition_key, []): + for exported_symbols_list in exported_symbols_list_target.files.to_list(): + split_extra_linkopts.append( + "-Wl,-exported_symbols_list,{}".format(exported_symbols_list.path), + ) + split_extra_link_inputs.append(exported_symbols_list) # TODO: remove when we drop Bazel 8 legacy_objc_compilation_support = getattr(apple_common, "compilation_support", None) @@ -311,8 +330,8 @@ def _link_multi_arch_binary( attr_linkopts = attr_linkopts, cc_linking_context = cc_linking_context, common_variables = common_variables, - extra_link_args = extra_linkopts, - extra_link_inputs = extra_link_inputs, + extra_link_args = split_extra_linkopts, + extra_link_inputs = split_extra_link_inputs, name = name, # TODO: Delete when we drop Bazel 8 support (see f4a3fa40) split_transition_key = split_transition_key, @@ -447,8 +466,8 @@ def _register_binary_linking_action( the entitlements will be embedded in a special section of the binary; when targeting non-simulator environments, this file is ignored (it is assumed that the entitlements will be provided during code signing). - exported_symbols_lists: List of `File`s containing exported symbols lists for the linker - to control symbol resolution. + exported_symbols_lists: Dictionary of exported-symbol-list targets under the same split + transition as `deps`. extra_linkopts: Extra linkopts to add to the linking action. extra_link_inputs: Extra link inputs to add to the linking action. extra_requested_features: Extra features as Strings requested of the underlying linker @@ -485,13 +504,6 @@ def _register_binary_linking_action( linkopts = [] link_inputs = [] - # Add linkopts/linker inputs that are common to all the rules. - for exported_symbols_list in exported_symbols_lists: - linkopts.append( - "-Wl,-exported_symbols_list,{}".format(exported_symbols_list.path), - ) - link_inputs.append(exported_symbols_list) - if entitlements: if platform_prerequisites and platform_prerequisites.platform.is_device and rule_descriptor and rule_descriptor.product_type != apple_product_type.kernel_extension: fail("entitlements should be None when targeting a device") @@ -542,6 +554,7 @@ def _register_binary_linking_action( ctx = ctx, avoid_deps = all_avoid_deps, cc_toolchains = cc_toolchains, + exported_symbols_lists = exported_symbols_lists, extra_linkopts = linkopts, extra_link_inputs = link_inputs, extra_requested_features = extra_requested_features, diff --git a/apple/internal/macos_rules.bzl b/apple/internal/macos_rules.bzl index 5bb8862b7d..68afd2e065 100644 --- a/apple/internal/macos_rules.bzl +++ b/apple/internal/macos_rules.bzl @@ -248,7 +248,7 @@ def _macos_application_impl(ctx): cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, stamp = ctx.attr.stamp, @@ -553,7 +553,7 @@ def _macos_bundle_impl(ctx): cc_toolchains = cc_toolchain_forwarder, bundle_loader = ctx.attr.bundle_loader, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = ["-bundle"], platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -808,7 +808,7 @@ def _macos_extension_impl(ctx): cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -1079,7 +1079,7 @@ def _macos_quick_look_plugin_impl(ctx): ctx, cc_toolchains = cc_toolchain_forwarder, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = ["-bundle"], platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -1335,7 +1335,7 @@ def _macos_kernel_extension_impl(ctx): ctx, cc_toolchains = cc_toolchain_forwarder, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -1580,7 +1580,7 @@ def _macos_spotlight_importer_impl(ctx): ctx, cc_toolchains = cc_toolchain_forwarder, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, stamp = ctx.attr.stamp, @@ -1823,7 +1823,7 @@ def _macos_xpc_service_impl(ctx): ctx, cc_toolchains = cc_toolchain_forwarder, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, stamp = ctx.attr.stamp, @@ -2123,7 +2123,7 @@ def _macos_command_line_application_impl(ctx): cc_toolchains = cc_toolchain_forwarder, # Command-line applications do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_link_inputs = extra_link_inputs, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, @@ -2310,7 +2310,7 @@ def _macos_dylib_impl(ctx): cc_toolchains = cc_toolchain_forwarder, # Dynamic libraries do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_link_inputs = extra_link_inputs, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, @@ -2985,7 +2985,7 @@ def _macos_framework_impl(ctx): avoid_deps = ctx.attr.frameworks, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -3270,7 +3270,7 @@ def _macos_dynamic_framework_impl(ctx): avoid_deps = ctx.attr.frameworks, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, diff --git a/apple/internal/rule_attrs.bzl b/apple/internal/rule_attrs.bzl index c2c7800033..0e9b0235bd 100644 --- a/apple/internal/rule_attrs.bzl +++ b/apple/internal/rule_attrs.bzl @@ -176,6 +176,7 @@ A list of strings representing extra flags that should be passed to `codesign`. ), "exported_symbols_lists": attr.label_list( allow_files = True, + cfg = deps_cfg, doc = """ A list of targets containing exported symbols lists files for the linker to control symbol resolution. diff --git a/apple/internal/testing/apple_test_bundle_support.bzl b/apple/internal/testing/apple_test_bundle_support.bzl index bb6882706e..87de9db78d 100644 --- a/apple/internal/testing/apple_test_bundle_support.bzl +++ b/apple/internal/testing/apple_test_bundle_support.bzl @@ -251,7 +251,7 @@ def _apple_test_bundle_impl(*, ctx, product_type): bundle_loader = bundle_loader, # Unit/UI tests do not use entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_link_inputs = extra_link_inputs, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, diff --git a/apple/internal/tvos_rules.bzl b/apple/internal/tvos_rules.bzl index e537e02c64..d931729185 100644 --- a/apple/internal/tvos_rules.bzl +++ b/apple/internal/tvos_rules.bzl @@ -225,7 +225,7 @@ def _tvos_application_impl(ctx): cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, stamp = ctx.attr.stamp, @@ -561,7 +561,7 @@ def _tvos_dynamic_framework_impl(ctx): avoid_deps = ctx.attr.frameworks, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = [ "-dynamiclib", "-Wl,-install_name,@rpath/{name}{extension}/{name}".format( @@ -850,7 +850,7 @@ def _tvos_framework_impl(ctx): avoid_deps = ctx.attr.frameworks, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = [ "-dynamiclib", "-Wl,-install_name,@rpath/{name}{extension}/{name}".format( @@ -1136,7 +1136,7 @@ def _tvos_extension_impl(ctx): cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, diff --git a/apple/internal/visionos_rules.bzl b/apple/internal/visionos_rules.bzl index c37ab4d359..490d5a5cf4 100644 --- a/apple/internal/visionos_rules.bzl +++ b/apple/internal/visionos_rules.bzl @@ -233,7 +233,7 @@ Resolved Xcode is version {xcode_version}. cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, stamp = ctx.attr.stamp, @@ -565,7 +565,7 @@ def _visionos_dynamic_framework_impl(ctx): avoid_deps = ctx.attr.frameworks, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = [ "-dynamiclib", "-Wl,-install_name,@rpath/{name}{extension}/{name}".format( @@ -854,7 +854,7 @@ def _visionos_framework_impl(ctx): avoid_deps = ctx.attr.frameworks, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = [ "-dynamiclib", "-Wl,-install_name,@rpath/{name}{extension}/{name}".format( @@ -1127,7 +1127,7 @@ def _visionos_extension_impl(ctx): cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = [ "-e", "_VisionExtensionMain", diff --git a/apple/internal/watchos_rules.bzl b/apple/internal/watchos_rules.bzl index 79062c5880..6ae08dded4 100644 --- a/apple/internal/watchos_rules.bzl +++ b/apple/internal/watchos_rules.bzl @@ -231,7 +231,7 @@ def _watchos_framework_impl(ctx): avoid_deps = ctx.attr.frameworks, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -513,7 +513,7 @@ def _watchos_dynamic_framework_impl(ctx): avoid_deps = ctx.attr.frameworks, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -1171,7 +1171,7 @@ def _watchos_extension_impl(ctx): cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = extra_linkopts, platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, @@ -1641,7 +1641,7 @@ delegate is referenced in the single-target `watchos_application`'s `deps`. cc_toolchains = cc_toolchain_forwarder, avoid_deps = ctx.attr.frameworks, entitlements = entitlements.linking, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = [], platform_prerequisites = platform_prerequisites, rule_descriptor = rule_descriptor, diff --git a/apple/internal/xcframework_rules.bzl b/apple/internal/xcframework_rules.bzl index 220d22205d..bdf341bfcf 100644 --- a/apple/internal/xcframework_rules.bzl +++ b/apple/internal/xcframework_rules.bzl @@ -598,7 +598,7 @@ def _apple_xcframework_impl(ctx): cc_toolchains = cc_toolchain_forwarder, # Frameworks do not have entitlements. entitlements = None, - exported_symbols_lists = ctx.files.exported_symbols_lists, + exported_symbols_lists = ctx.split_attr.exported_symbols_lists, extra_linkopts = [ # iOS, tvOS and watchOS single target app framework binaries live in # Application.app/Frameworks/Framework.framework/Framework diff --git a/doc/rules-linker.md b/doc/rules-linker.md index a4707b5200..fdc1aeb5de 100755 --- a/doc/rules-linker.md +++ b/doc/rules-linker.md @@ -21,6 +21,9 @@ lookup and `dlsym` do not necessarily leave static undefined references. That conservative policy can pull otherwise-unreferenced archive members into the framework; set `preserve_all_non_swift_exports = False` only after auditing runtime lookup and listing its roots in `additional_exported_symbols_lists`. +Authored roots are validated independently for each architecture, and the +`report` output group on this target lists selected policy buckets and missing +entries. ```starlark apple_exported_symbols_list( @@ -51,7 +54,7 @@ current consumers are not a stable public ABI contract. | :------------- | :------------- | :------------- | :------------- | :------------- | | name | A unique name for this target. | Name | required | | | deps | The same library roots linked into the private framework. Their transitive static definitions are the maximum possible export surface. | List of labels | required | | -| additional_exported_symbols_lists | Optional authored symbol lists for runtime-discovered entry points that do not appear as undefined symbols in `clients`. | List of labels | optional | `[]` | +| additional_exported_symbols_lists | Optional authored symbol lists for runtime-discovered entry points that do not appear as undefined symbols in `clients`. Entries are retained only when that architecture's framework inputs define them; missing entries are listed in the `report` output group on this target. | List of labels | optional | `[]` | | clients | The complete set of application and extension link roots that may load the framework. Pass library or binary targets that provide `CcInfo`, not bundle targets that embed the framework, to avoid a dependency cycle. | List of labels | required | | | nm | Optional `llvm-nm` executable. By default the selected Xcode's `llvm-nm` is used through `xcrun`. Override this when the link inputs come from another LLVM toolchain, especially when they contain LLVM bitcode. | Label | optional | `None` | | preserve_all_non_swift_exports | Whether to retain every non-Swift definition conservatively for Objective-C runtime and `dlsym` lookup. This can pull otherwise-unreferenced archive members into the framework. Set this to `False` only when all runtime-discovered entry points are absent or listed in `additional_exported_symbols_lists`. | Boolean | optional | `True` | diff --git a/test/starlark_tests/exported_symbols_list_tests.bzl b/test/starlark_tests/exported_symbols_list_tests.bzl index ca919ebd93..e254151ad4 100644 --- a/test/starlark_tests/exported_symbols_list_tests.bzl +++ b/test/starlark_tests/exported_symbols_list_tests.bzl @@ -88,6 +88,31 @@ def exported_symbols_list_test_suite(name): tags = [name], ) + # The generated list must be derived independently for each architecture. + # Otherwise one slice can receive a symbol that only exists in another + # slice, which makes Apple's linker reject the fat framework build. + for architecture, expected_symbol, other_symbol in [ + ("arm64", "_arm64RuntimeDiscoveredEntry", "_x8664RuntimeDiscoveredEntry"), + ("x86_64", "_x8664RuntimeDiscoveredEntry", "_arm64RuntimeDiscoveredEntry"), + ]: + archive_contents_test( + name = "{}_multi_arch_{}_framework_exports_test".format(name, architecture), + target_under_test = "//test/starlark_tests/targets_under_test/ios:client_export_framework", + binary_contains_symbols = [ + expected_symbol, + "_runtimeDiscoveredEntry", + ], + binary_not_contains_symbols = [other_symbol], + binary_test_architecture = architecture, + binary_test_file = "$BUNDLE_ROOT/client_export_framework", + build_type = "simulator", + compilation_mode = "opt", + cpus = { + "ios_multi_cpus": ["sim_arm64", "x86_64"], + }, + tags = [name], + ) + native.test_suite( name = name, tags = [name], diff --git a/test/starlark_tests/targets_under_test/ios/BUILD b/test/starlark_tests/targets_under_test/ios/BUILD index d365f55218..80019e7625 100644 --- a/test/starlark_tests/targets_under_test/ios/BUILD +++ b/test/starlark_tests/targets_under_test/ios/BUILD @@ -103,7 +103,14 @@ swift_library( write_file( name = "client_export_runtime_src", out = "ClientExportRuntime.m", - content = ["int runtimeDiscoveredEntry(void) { return 1; }"], + content = [ + "int runtimeDiscoveredEntry(void) { return 1; }", + "#if defined(__arm64__)", + "int arm64RuntimeDiscoveredEntry(void) { return 2; }", + "#elif defined(__x86_64__)", + "int x8664RuntimeDiscoveredEntry(void) { return 3; }", + "#endif", + ], tags = common.fixture_tags, ) diff --git a/tools/exported_symbols_list/exported_symbols_list.py b/tools/exported_symbols_list/exported_symbols_list.py index 268acc46da..0f6c08ed85 100644 --- a/tools/exported_symbols_list/exported_symbols_list.py +++ b/tools/exported_symbols_list/exported_symbols_list.py @@ -109,7 +109,8 @@ def select_exports( """Applies the closed-world framework export policy. Returns the statically referenced exports, the conservatively retained - non-Swift exports, and their union with authored runtime roots. + non-Swift exports, and their union with authored runtime roots that are + defined by the framework. """ statically_used = defined_exports & client_imports non_swift_exports = set() @@ -119,13 +120,48 @@ def select_exports( for symbol in defined_exports if not symbol.startswith(_SWIFT_SYMBOL_PREFIX) } + defined_additional_exports = defined_exports & additional_exports return ( statically_used, non_swift_exports, - statically_used | non_swift_exports | additional_exports, + statically_used | non_swift_exports | defined_additional_exports, ) +def export_report( + *, + additional_exports: set[str], + allowlist: list[str], + client_imports: set[str], + client_input_count: int, + defined_exports: set[str], + framework_input_count: int, + non_swift_exports: set[str], + preserve_all_non_swift_exports: bool, + statically_used: set[str], +) -> dict[str, object]: + """Returns deterministic audit data for the selected export surface.""" + defined_additional_exports = additional_exports & defined_exports + return { + "additional_exports": len(additional_exports), + "additional_export_symbols": sorted(defined_additional_exports), + "allowlist_exports": len(allowlist), + "allowlist_symbols": allowlist, + "client_imports": len(client_imports), + "client_inputs": client_input_count, + "defined_exports": len(defined_exports), + "framework_inputs": framework_input_count, + "missing_additional_exports": sorted( + additional_exports - defined_exports + ), + "non_swift_exports": len(non_swift_exports), + "non_swift_export_symbols": sorted(non_swift_exports), + "preserve_all_non_swift_exports": preserve_all_non_swift_exports, + "statically_used_exports": len(statically_used), + "statically_used_export_symbols": sorted(statically_used), + } + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--framework-inputs", required=True, type=Path) @@ -173,21 +209,21 @@ def main() -> None: ) args.report.write_text( json.dumps( - { - "additional_exports": len(additional_exports), - "allowlist_exports": len(allowlist), - "client_imports": len(client_imports), - "client_inputs": len(_manifest_inputs(args.client_inputs)), - "defined_exports": len(defined_exports), - "framework_inputs": len( + export_report( + additional_exports=additional_exports, + allowlist=allowlist, + client_imports=client_imports, + client_input_count=len(_manifest_inputs(args.client_inputs)), + defined_exports=defined_exports, + framework_input_count=len( _manifest_inputs(args.framework_inputs) ), - "non_swift_exports": len(non_swift_exports), - "preserve_all_non_swift_exports": ( + non_swift_exports=non_swift_exports, + preserve_all_non_swift_exports=( args.preserve_all_non_swift_exports ), - "statically_used_exports": len(statically_used), - }, + statically_used=statically_used, + ), indent=2, sort_keys=True, ) diff --git a/tools/exported_symbols_list/exported_symbols_list_test.py b/tools/exported_symbols_list/exported_symbols_list_test.py index 4844e72ba4..d3d4f2abca 100644 --- a/tools/exported_symbols_list/exported_symbols_list_test.py +++ b/tools/exported_symbols_list/exported_symbols_list_test.py @@ -17,6 +17,7 @@ from pathlib import Path from tools.exported_symbols_list.exported_symbols_list import ( + export_report, parse_nm_output, read_additional_exports, select_exports, @@ -24,6 +25,45 @@ class ExportedSymbolsListTest(unittest.TestCase): + def test_export_report_lists_policy_buckets_and_missing_roots(self) -> None: + self.assertEqual( + export_report( + additional_exports={"_dynamic", "_missing"}, + allowlist=["_c_entry", "_dynamic", "_$s4Test4usedyyF"], + client_imports={"_$s4Test4usedyyF", "_system"}, + client_input_count=2, + defined_exports={ + "_c_entry", + "_dynamic", + "_$s4Test4usedyyF", + }, + framework_input_count=3, + non_swift_exports={"_c_entry"}, + preserve_all_non_swift_exports=True, + statically_used={"_$s4Test4usedyyF"}, + ), + { + "additional_export_symbols": ["_dynamic"], + "additional_exports": 2, + "allowlist_exports": 3, + "allowlist_symbols": [ + "_c_entry", + "_dynamic", + "_$s4Test4usedyyF", + ], + "client_imports": 2, + "client_inputs": 2, + "defined_exports": 3, + "framework_inputs": 3, + "missing_additional_exports": ["_missing"], + "non_swift_export_symbols": ["_c_entry"], + "non_swift_exports": 1, + "preserve_all_non_swift_exports": True, + "statically_used_export_symbols": ["_$s4Test4usedyyF"], + "statically_used_exports": 1, + }, + ) + def test_parse_nm_output_keeps_only_exportable_external_symbols(self) -> None: output = """ _Source.swift.bc: @@ -54,6 +94,7 @@ def test_parse_nm_output_ignores_headings_and_non_macho_names(self) -> None: def test_select_exports_keeps_client_swift_and_all_non_swift(self) -> None: defined_exports = { "_$s4Test4usedyyF", + "_$s4Test12dynamicSwiftyyF", "_$s4Test6unusedyyF", "_OBJC_CLASS_$_RuntimeDiscoveredType", "_c_entry_point", @@ -66,7 +107,10 @@ def test_select_exports_keeps_client_swift_and_all_non_swift(self) -> None: statically_used, non_swift_exports, allowlist = select_exports( defined_exports, client_imports, - {"_$s4Test12dynamicSwiftyyF"}, + { + "_$s4Test12dynamicSwiftyyF", + "_$s4Test14missingDynamicyyF", + }, preserve_all_non_swift_exports=True, )