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..4019971b93 --- /dev/null +++ b/apple/internal/exported_symbols_list.bzl @@ -0,0 +1,235 @@ +# 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`. 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( + 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`. +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( + 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/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/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..fdc1aeb5de 100755 --- a/doc/rules-linker.md +++ b/doc/rules-linker.md @@ -2,6 +2,64 @@ 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`.
+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(
+ 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`. 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` |
+
+
## 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..e254151ad4
--- /dev/null
+++ b/test/starlark_tests/exported_symbols_list_tests.bzl
@@ -0,0 +1,119 @@
+# 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],
+ )
+
+ # 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 e11ab7f5ca..80019e7625 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,113 @@ 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; }",
+ "#if defined(__arm64__)",
+ "int arm64RuntimeDiscoveredEntry(void) { return 2; }",
+ "#elif defined(__x86_64__)",
+ "int x8664RuntimeDiscoveredEntry(void) { return 3; }",
+ "#endif",
+ ],
+ 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..0f6c08ed85
--- /dev/null
+++ b/tools/exported_symbols_list/exported_symbols_list.py
@@ -0,0 +1,236 @@
+#!/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 that are
+ defined by the framework.
+ """
+ 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)
+ }
+ defined_additional_exports = defined_exports & additional_exports
+ return (
+ statically_used,
+ non_swift_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)
+ 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(
+ 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=non_swift_exports,
+ preserve_all_non_swift_exports=(
+ args.preserve_all_non_swift_exports
+ ),
+ statically_used=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..d3d4f2abca
--- /dev/null
+++ b/tools/exported_symbols_list/exported_symbols_list_test.py
@@ -0,0 +1,162 @@
+# 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 (
+ export_report,
+ parse_nm_output,
+ read_additional_exports,
+ select_exports,
+)
+
+
+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:
+---------------- (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",
+ "_$s4Test12dynamicSwiftyyF",
+ "_$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",
+ "_$s4Test14missingDynamicyyF",
+ },
+ 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()