Repository: conan-io/conan
Pull request #11365 (2022-05-31 14:25:39)

Add fix_apple_shared_install_name tool
Changelog: Feature: Do not automatically fix the shared libraries to add the rpath in Apple and add an external tool `tools.apple.fix_apple_shared_install_name` to do it optionally in recipes for packages that do not set the correct `LC_ID_DYLIB`.
Docs: https://github.com/conan-io/docs/pull/XXXX

Closes: https://github.com/conan-io/conan/issues/10253


Patch:
diff --git a/conan/tools/apple/__init__.py b/conan/tools/apple/__init__.py
index b3a7cb5a3..3ad2091bc 100644
--- a/conan/tools/apple/__init__.py
+++ b/conan/tools/apple/__init__.py
@@ -5,6 +5,7 @@
 # from conan.tools.apple.apple import apple_sdk_name
 # from conan.tools.apple.apple import apple_deployment_target_flag
 # from conan.tools.apple.apple import to_apple_arch
+from conan.tools.apple.apple import fix_apple_shared_install_name
 from conan.tools.apple.xcodedeps import XcodeDeps
 from conan.tools.apple.xcodebuild import XcodeBuild
 from conan.tools.apple.xcodetoolchain import XcodeToolchain
diff --git a/conan/tools/apple/apple.py b/conan/tools/apple/apple.py
index 2b16cc83e..8fd7fd569 100644
--- a/conan/tools/apple/apple.py
+++ b/conan/tools/apple/apple.py
@@ -1,5 +1,6 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*-
+import os
 
 from conans.util.runners import check_output_runner
 
@@ -152,3 +153,42 @@ class XCRun(object):
     def libtool(self):
         """path to libtool"""
         return self.find('libtool')
+
+
+def fix_apple_shared_install_name(conanfile):
+
+    def _get_install_name(path_to_dylib):
+        command = "otool -D {}".format(path_to_dylib)
+        install_name = check_output_runner(command).strip().split(":")[1].strip()
+        return install_name
+
+    def _osx_collect_dylibs(lib_folder):
+        return [os.path.join(full_folder, f) for f in os.listdir(lib_folder) if f.endswith(".dylib")
+                and not os.path.islink(os.path.join(lib_folder, f))]
+
+    def _fix_install_name(dylib_path, new_name):
+        command = f"install_name_tool {dylib_path} -id {new_name}"
+        conanfile.run(command)
+
+    def _fix_dep_name(dylib_path, old_name, new_name):
+        command = f"install_name_tool {dylib_path} -change {old_name} {new_name}"
+        conanfile.run(command)
+
+    substitutions = {}
+
+    if is_apple_os(conanfile.settings.get_safe("os")) and conanfile.options.get_safe("shared", False):
+        libdirs = getattr(conanfile.cpp.package, "libdirs")
+        for libdir in libdirs:
+            full_folder = os.path.join(conanfile.package_folder, libdir)
+            shared_libs = _osx_collect_dylibs(full_folder)
+            # fix LC_ID_DYLIB in first pass
+            for shared_lib in shared_libs:
+                install_name = _get_install_name(shared_lib)
+                rpath_name = f"@rpath/{os.path.basename(install_name)}"
+                _fix_install_name(shared_lib, rpath_name)
+                substitutions[install_name] = rpath_name
+
+            # fix dependencies in second pass
+            for shared_lib in shared_libs:
+                for old, new in substitutions.items():
+                    _fix_dep_name(shared_lib, old, new)
diff --git a/conan/tools/files/files.py b/conan/tools/files/files.py
index 3a32abdc6..4d7f1e4b2 100644
--- a/conan/tools/files/files.py
+++ b/conan/tools/files/files.py
@@ -13,9 +13,11 @@ from fnmatch import fnmatch
 import six
 
 from conan.tools import CONAN_TOOLCHAIN_ARGS_FILE, CONAN_TOOLCHAIN_ARGS_SECTION
+from conan.tools.apple.apple import is_apple_os
 from conans.client.downloaders.download import run_downloader
 from conans.errors import ConanException
 from conans.util.files import rmdir as _internal_rmdir
+from conans.util.runners import check_output_runner
 
 if six.PY3:  # Remove this IF in develop2
     from shutil import which
diff --git a/conan/tools/gnu/autotools.py b/conan/tools/gnu/autotools.py
index 37520577c..0db4bd302 100644
--- a/conan/tools/gnu/autotools.py
+++ b/conan/tools/gnu/autotools.py
@@ -54,40 +54,9 @@ class Autotools(object):
         command = join_arguments([make_program, target, str_args, str_extra_args, jobs])
         self._conanfile.run(command)
 
-    def _fix_osx_shared_install_name(self):
-
-        def _osx_collect_dylibs(lib_folder):
-            return [f for f in os.listdir(lib_folder) if f.endswith(".dylib")
-                    and not os.path.islink(os.path.join(lib_folder, f))]
-
-        def _fix_install_name(lib_name, lib_folder):
-            command = "install_name_tool -id @rpath/{} {}".format(lib_name, os.path.join(lib_folder,
-                                                                                         lib_name))
-            self._conanfile.run(command)
-
-        def _is_modified_install_name(lib_name, full_folder, libdir):
-            """
-            Check that the user did not change the default install_name using the install_name
-            linker flag in that case we do not touch this field
-            """
-            command = "otool -D {}".format(os.path.join(full_folder, lib_name))
-            install_path = check_output_runner(command).strip().split(":")[1].strip()
-            default_path = str(os.path.join("/", libdir, shared_lib))
-            return False if default_path == install_path else True
-
-        libdirs = getattr(self._conanfile.cpp.package, "libdirs")
-        for libdir in libdirs:
-            full_folder = os.path.join(self._conanfile.package_folder, libdir)
-            shared_libs = _osx_collect_dylibs(full_folder)
-            for shared_lib in shared_libs:
-                if not _is_modified_install_name(shared_lib, full_folder, libdir):
-                    _fix_install_name(shared_lib, full_folder)
-
     def install(self, args=None):
         args = args if args is not None else ["DESTDIR={}".format(self._conanfile.package_folder)]
         self.make(target="install", args=args)
-        if self._conanfile.settings.get_safe("os") == "Macos" and self._conanfile.options.get_safe("shared", False):
-            self._fix_osx_shared_install_name()
 
     def autoreconf(self, args=None):
         args = args or []
diff --git a/conan/tools/google/bazeldeps.py b/conan/tools/google/bazeldeps.py
index 4df876e3b..8a7b1af20 100644
--- a/conan/tools/google/bazeldeps.py
+++ b/conan/tools/google/bazeldeps.py
@@ -66,6 +66,14 @@ class BazelDeps(object):
             )
             {% endfor %}
 
+            {% for libname, (lib_path, dll_path) in shared_with_interface_libs.items() %}
+            cc_import(
+                name = "{{ libname }}_precompiled",
+                interface_library = "{{ lib_path }}",
+                shared_library = "{{ dll_path }}",
+            )
+            {% endfor %}
+
             cc_library(
                 name = "{{ name }}",
                 {% if headers %}
@@ -81,11 +89,14 @@ class BazelDeps(object):
                 linkopts = [{{ linkopts }}],
                 {% endif %}
                 visibility = ["//visibility:public"],
-                {% if libs %}
+                {% if libs or shared_with_interface_libs %}
                 deps = [
                 {% for lib in libs %}
                 ":{{ lib }}_precompiled",
                 {% endfor %}
+                {% for lib in shared_with_interface_libs %}
+                ":{{ lib }}_precompiled",
+                {% endfor %}
                 {% for dep in dependencies %}
                 "@{{ dep }}",
                 {% endfor %}
@@ -137,16 +148,26 @@ class BazelDeps(object):
         for req, dep in dependency.dependencies.items():
             dependencies.append(dep.ref.name)
 
+        shared_library = dependency.options.get_safe("shared") if dependency.options else False
+
         libs = {}
+        shared_with_interface_libs = {}
         for lib in cpp_info.libs:
-            real_path = self._get_lib_file_paths(cpp_info.libdirs, lib)
-            if real_path:
-                libs[lib] = _relativize_path(real_path, package_folder)
+            lib_path, dll_path = self._get_lib_file_paths(shared_library,
+                                                          cpp_info.libdirs,
+                                                          cpp_info.bindirs,
+                                                          lib)
+            if lib_path and dll_path:
+                shared_with_interface_libs[lib] = (
+                    _relativize_path(lib_path, package_folder),
+                    _relativize_path(dll_path, package_folder))
+            elif lib_path:
+                libs[lib] = _relativize_path(lib_path, package_folder)
 
-        shared_library = dependency.options.get_safe("shared") if dependency.options else False
         context = {
             "name": dependency.ref.name,
             "libs": libs,
+            "shared_with_interface_libs": shared_with_interface_libs,
             "libdir": lib_dir,
             "headers": headers,
             "includes": includes,
@@ -158,12 +179,31 @@ class BazelDeps(object):
         content = Template(template).render(**context)
         return content
 
-    def _get_lib_file_paths(self, libdirs, lib):
+    def _get_dll_file_paths(self, bindirs, expected_file):
+        """Find a given dll file in bin directories. If found return the full
+        path, otherwise return None.
+        """
+        for each_bin in bindirs:
+            if not os.path.exists(each_bin):
+                self._conanfile.output.warning("The bin folder doesn't exist: {}".format(each_bin))
+                continue
+            files = os.listdir(each_bin)
+            for f in files:
+                full_path = os.path.join(each_bin, f)
+                if not os.path.isfile(full_path):
+                    continue
+                if f == expected_file:
+                    return full_path
+        return None
+
+    def _get_lib_file_paths(self, shared, libdirs, bindirs, lib):
         for libdir in libdirs:
             if not os.path.exists(libdir):
                 self._conanfile.output.warning("The library folder doesn't exist: {}".format(libdir))
                 continue
             files = os.listdir(libdir)
+            lib_basename = None
+            lib_path = None
             for f in files:
                 full_path = os.path.join(libdir, f)
                 if not os.path.isfile(full_path):  # Make sure that directories are excluded
@@ -171,15 +211,26 @@ class BazelDeps(object):
                 # Users may not name their libraries in a conventional way. For example, directly
                 # use the basename of the lib file as lib name.
                 if f == lib:
-                    return full_path
+                    lib_basename = f
+                    lib_path = full_path
+                    break
                 name, ext = os.path.splitext(f)
                 if ext in (".so", ".lib", ".a", ".dylib", ".bc"):
                     if ext != ".lib" and name.startswith("lib"):
                         name = name[3:]
                 if lib == name:
-                    return full_path
+                    lib_basename = f
+                    lib_path = full_path
+                    break
+            if lib_path is not None:
+                dll_path = None
+                name, ext = os.path.splitext(lib_basename)
+                if shared and ext == ".lib":
+                    dll_path = self._get_dll_file_paths(bindirs, name+".dll")
+                return lib_path, dll_path
         self._conanfile.output.warning("The library {} cannot be found in the "
                                        "dependency".format(lib))
+        return None, None
 
     def _create_new_local_repository(self, dependency, dependency_buildfile_name):
         if dependency.package_folder is None:
diff --git a/conans/assets/templates/new_v2_autotools.py b/conans/assets/templates/new_v2_autotools.py
index 0039d7b7b..cc717a5d6 100644
--- a/conans/assets/templates/new_v2_autotools.py
+++ b/conans/assets/templates/new_v2_autotools.py
@@ -8,6 +8,7 @@ conanfile_lib = textwrap.dedent("""
     from conan import ConanFile
     from conan.tools.gnu import AutotoolsToolchain, Autotools
     from conan.tools.layout import basic_layout
+    from conan.tools.apple import fix_apple_shared_install_name
 
 
     class {package_name}Conan(ConanFile):
@@ -48,6 +49,7 @@ conanfile_lib = textwrap.dedent("""
         def package(self):
             autotools = Autotools(self)
             autotools.install()
+            fix_apple_shared_install_name(self)
 
         def package_info(self):
             self.cpp_info.libs = ["{name}"]


Test patch:
diff --git a/conans/test/functional/toolchains/gnu/test_v2_autotools_template.py b/conans/test/functional/toolchains/gnu/test_v2_autotools_template.py
index c9c568e93..0e72f4d03 100644
--- a/conans/test/functional/toolchains/gnu/test_v2_autotools_template.py
+++ b/conans/test/functional/toolchains/gnu/test_v2_autotools_template.py
@@ -167,3 +167,142 @@ def test_autotools_relocatable_libs_darwin_downloaded():
     client2.run_command("build-release/greet")
     assert "Hello World Release!" in client2.out
 
+
+@pytest.mark.skipif(platform.system() not in ["Darwin"], reason="Only affects apple platforms")
+@pytest.mark.tool_autotools()
+def test_autotools_fix_shared_libs():
+    """
+    From comments in: https://github.com/conan-io/conan/pull/11365
+
+    Case 1:
+    libopencv_core.3.4.17.dylib
+    libopencv_core.3.4.dylib (symlink) -> libopencv_core.3.4.17.dylib
+    libopencv_core.dylib (symlink) -> libopencv_core.3.4.dylib
+
+    Install name in libopencv_core.3.4.17.dylib is libopencv_core.3.4.dylib NOT the dylib name
+    So we have to add the rpath to that.
+
+    Case 2:
+    libopencv_core.dylib
+    libopencv_imgproc.dylib
+
+    libopencv_imgproc.dylib depends on libopencv_core.dylib and declares that dependency not using the
+    @rpath, we have to make sure that we patch the dependencies in the dylibs using install_name_tool -change
+
+    Let's create a Conan package with two libraries: bye and hello (bye depends on hello)
+    and recreate this whole situation to check that we are correctly fixing the dylibs
+    """
+    client = TestClient(path_with_spaces=False)
+    client.run("new hello/0.1 --template=autotools_lib")
+
+    conanfile = textwrap.dedent("""
+        import os
+
+        from conan import ConanFile
+        from conan.tools.gnu import AutotoolsToolchain, Autotools
+        from conan.tools.layout import basic_layout
+        from conan.tools.apple import fix_apple_shared_install_name
+
+
+        class HelloConan(ConanFile):
+            name = "hello"
+            version = "0.1"
+            settings = "os", "compiler", "build_type", "arch"
+            options = {"shared": [True, False], "fPIC": [True, False]}
+            default_options = {"shared": False, "fPIC": True}
+            exports_sources = "configure.ac", "Makefile.am", "src/*"
+
+            def layout(self):
+                basic_layout(self)
+
+            def generate(self):
+                at_toolchain = AutotoolsToolchain(self)
+                at_toolchain.generate()
+
+            def build(self):
+                autotools = Autotools(self)
+                autotools.autoreconf()
+                autotools.configure()
+                autotools.make()
+
+            def package(self):
+                autotools = Autotools(self)
+                autotools.install()
+                # before fixing the names we try to reproduce the two cases explained
+                # in the test that dylib name and install name are not the same
+                self.run("install_name_tool {} -id /lib/libbye.dylib".format(os.path.join(self.package_folder,
+                                                                                          "lib", "libbye.0.dylib")))
+                # also change that in the libbye dependencies
+                self.run("install_name_tool {} -change /lib/libhello.0.dylib /lib/libhello.dylib".format(os.path.join(self.package_folder,
+                                                                                                         "lib", "libbye.0.dylib")))
+                self.run("install_name_tool {} -id /lib/libhello.dylib".format(os.path.join(self.package_folder,
+                                                                                            "lib","libhello.0.dylib")))
+                fix_apple_shared_install_name(self)
+
+            def package_info(self):
+                self.cpp_info.libs = ["hello", "bye"]
+    """)
+
+    bye_cpp = textwrap.dedent("""
+        #include <iostream>
+        #include "hello.h"
+        #include "bye.h"
+        void bye(){
+            hello();
+            std::cout << "Bye, bye!" << std::endl;
+        }
+    """)
+
+    bye_h = textwrap.dedent("""
+        #pragma once
+        void bye();
+    """)
+
+    makefile_am = textwrap.dedent("""
+        lib_LTLIBRARIES = libhello.la libbye.la
+
+        libhello_la_SOURCES = hello.cpp hello.h
+        libhello_la_HEADERS = hello.h
+        libhello_ladir = $(includedir)
+
+        libbye_la_SOURCES = bye.cpp bye.h
+        libbye_la_HEADERS = bye.h
+        libbye_ladir = $(includedir)
+        libbye_la_LIBADD = libhello.la
+    """)
+
+    test_src = textwrap.dedent("""
+        #include "bye.h"
+        int main() { bye(); }
+    """)
+
+    client.save({
+        "src/makefile.am": makefile_am,
+        "src/bye.cpp": bye_cpp,
+        "src/bye.h": bye_h,
+        "test_package/main.cpp": test_src,
+        "conanfile.py": conanfile,
+    })
+
+    client.run("create . -o hello:shared=True -tf=None")
+
+    package_id = re.search(r"Package (\S+)", str(client.out)).group(1)
+    package_id = package_id.replace("'", "")
+    pref = PackageReference(ConanFileReference.loads("hello/0.1"), package_id)
+    package_folder = client.cache.package_layout(pref.ref).package(pref)
+
+    # install name fixed
+    client.run_command("otool -D {}".format(os.path.join(package_folder, "lib", "libhello.0.dylib")))
+    assert "@rpath/libhello.dylib" in client.out
+    client.run_command("otool -D {}".format(os.path.join(package_folder, "lib", "libbye.0.dylib")))
+    assert "@rpath/libbye.dylib" in client.out
+
+    # dependencies fixed
+    client.run_command("otool -L {}".format(os.path.join(package_folder, "lib", "libbye.0.dylib")))
+    assert "/lib/libhello.dylib (compatibility version 1.0.0, current version 1.0.0)" not in client.out
+    assert "/lib/libbye.dylib (compatibility version 1.0.0, current version 1.0.0)" not in client.out
+    assert "@rpath/libhello.dylib (compatibility version 1.0.0, current version 1.0.0)" in client.out
+    assert "@rpath/libbye.dylib (compatibility version 1.0.0, current version 1.0.0)" in client.out
+
+    client.run("test test_package hello/0.1@ -o hello:shared=True")
+    assert "Bye, bye!" in client.out
diff --git a/conans/test/unittests/tools/google/test_bazeldeps.py b/conans/test/unittests/tools/google/test_bazeldeps.py
index 789591bd5..acef3622e 100644
--- a/conans/test/unittests/tools/google/test_bazeldeps.py
+++ b/conans/test/unittests/tools/google/test_bazeldeps.py
@@ -11,10 +11,21 @@ from conans.model.build_info import CppInfo
 from conans.model.conanfile_interface import ConanFileInterface
 from conans.model.dependencies import Requirement, ConanFileDependencies
 from conans.model.ref import ConanFileReference
+from conans.test.utils.mocks import MockOptions
 from conans.test.utils.test_files import temp_folder
 from conans.util.files import save
 
 
+class MockConanFileDeps(ConanFile):
+    def __init__(self, deps, *args, **kwargs):
+        super(MockConanFileDeps, self).__init__(*args, **kwargs)
+        self._deps = deps
+
+    @property
+    def dependencies(self):
+        return self._deps
+
+
 def test_bazeldeps_dependency_buildfiles():
     conanfile = ConanFile(Mock(), None)
 
@@ -165,6 +176,57 @@ def test_bazeldeps_interface_buildfiles():
         assert(dependency_content == 'load("@rules_cc//cc:defs.bzl","cc_import","cc_library")cc_library(name="OriginalDepName",hdrs=glob(["include/**"]),includes=["include"],visibility=["//visibility:public"],)')
 
 
+def test_bazeldeps_shared_library_interface_buildfiles():
+
+    cpp_info = CppInfo("mypkg", "dummy_root_folder2")
+    cpp_info.libs = ["lib1"]
+
+    options = MockOptions({"shared": True})
+    conanfile_dep = MockConanFileDeps(ConanFileDependencies({}), Mock(), None)
+    conanfile_dep.options = options
+    conanfile_dep.cpp_info = cpp_info
+    conanfile_dep._conan_node = Mock()
+    conanfile_dep.folders.set_base_package(temp_folder())
+    conanfile_dep._conan_node.ref = ConanFileReference.loads("OriginalDepName/1.0")
+
+    package_folder = temp_folder()
+    save(os.path.join(package_folder, "lib", "lib1.lib"), "")
+    save(os.path.join(package_folder, "bin", "lib1.dll"), "")
+    conanfile_dep.folders.set_base_package(package_folder)
+
+    req = Requirement(ConanFileReference.loads("OriginalDepName/1.0"))
+    mock_deps = ConanFileDependencies(
+        {req: ConanFileInterface(conanfile_dep)})
+    conanfile = MockConanFileDeps(mock_deps, Mock(), None)
+
+    bazeldeps = BazelDeps(conanfile)
+
+    dependency = next(iter(bazeldeps._conanfile.dependencies.host.values()))
+    dependency_content = re.sub(r"\s",
+                                "",
+                                bazeldeps._get_dependency_buildfile_content(dependency))
+    expected_content = """
+load("@rules_cc//cc:defs.bzl","cc_import","cc_library")
+
+cc_import(
+    name = "lib1_precompiled",
+    interface_library = "lib/lib1.lib",
+    shared_library = "bin/lib1.dll",
+)
+
+cc_library(
+    name = "OriginalDepName",
+    hdrs=glob(["include/**"]),
+    includes=["include"],
+    visibility=["//visibility:public"],
+    deps = [
+        ":lib1_precompiled",
+    ],
+)
+"""
+    assert(dependency_content == re.sub(r"\s", "", expected_content))
+
+
 def test_bazeldeps_main_buildfile():
     expected_content = [
         'def load_conan_dependencies():',