Repository: conan-io/conan

Issue:
Fix symlinks not copied during export
- [x] Refer to the issue that supports this Pull Request: fixes #3258
- [x] If the issue has missing info, explain the purpose/use case/pain/need that covers this Pull Request.
- [x] I've read the [Contributing guide](https://raw.githubusercontent.com/conan-io/conan/develop/.github/CONTRIBUTING.md).
- [x] I've followed the PEP8 style guides for Python code.
- [ ] I've opened another PR in the Conan docs repo to the ``develop`` branch, documenting this one. Also adding a description of the changes in the ``changelog.rst`` file. https://github.com/conan-io/docs



Discussion:

Resolving patch:
diff --git a/conans/client/file_copier.py b/conans/client/file_copier.py
--- a/conans/client/file_copier.py
+++ b/conans/client/file_copier.py
@@ -91,6 +91,14 @@ def _filter_files(self, src, pattern, links, excludes, ignore_case):
         """
         filenames = []
         linked_folders = []
+        if excludes:
+            if not isinstance(excludes, (tuple, list)):
+                excludes = (excludes, )
+            if ignore_case:
+                excludes = [e.lower() for e in excludes]
+        else:
+            excludes = []
+
         for root, subfolders, files in os.walk(src, followlinks=True):
             if root in self._excluded:
                 subfolders[:] = []
@@ -112,6 +120,11 @@ def _filter_files(self, src, pattern, links, excludes, ignore_case):
                     pass
 
             relative_path = os.path.relpath(root, src)
+            for exclude in excludes:
+                if fnmatch.fnmatch(relative_path, exclude):
+                    subfolders[:] = []
+                    files = []
+                    break
             for f in files:
                 relative_name = os.path.normpath(os.path.join(relative_path, f))
                 filenames.append(relative_name)
@@ -121,13 +134,8 @@ def _filter_files(self, src, pattern, links, excludes, ignore_case):
             pattern = pattern.lower()
 
         files_to_copy = fnmatch.filter(filenames, pattern)
-        if excludes:
-            if not isinstance(excludes, (tuple, list)):
-                excludes = (excludes, )
-            if ignore_case:
-                excludes = [e.lower() for e in excludes]
-            for exclude in excludes:
-                files_to_copy = [f for f in files_to_copy if not fnmatch.fnmatch(f, exclude)]
+        for exclude in excludes:
+            files_to_copy = [f for f in files_to_copy if not fnmatch.fnmatch(f, exclude)]
 
         if ignore_case:
             files_to_copy = [filenames[f] for f in files_to_copy]
@@ -136,8 +144,20 @@ def _filter_files(self, src, pattern, links, excludes, ignore_case):
 
     @staticmethod
     def _link_folders(src, dst, linked_folders):
+        created_links = []
         for linked_folder in linked_folders:
-            link = os.readlink(os.path.join(src, linked_folder))
+            src_link = os.path.join(src, linked_folder)
+            # Discard symlinks that go out of the src folder
+            abs_path = os.path.realpath(src_link)
+            relpath = os.path.relpath(abs_path, src)
+            if relpath.startswith("."):
+                continue
+            
+            link = os.readlink(src_link)
+            # Absoluted path symlinks are a problem, convert it to relative
+            if os.path.isabs(link):
+                link = os.path.relpath(link, os.path.dirname(src_link))
+
             dst_link = os.path.join(dst, linked_folder)
             try:
                 # Remove the previous symlink
@@ -148,12 +168,19 @@ def _link_folders(src, dst, linked_folders):
             # e.g.: os.symlink("test/bar", "./foo/test_link") will create a link to foo/test/bar in ./foo/test_link
             mkdir(os.path.dirname(dst_link))
             os.symlink(link, dst_link)
+            created_links.append(dst_link)
         # Remove empty links
-        for linked_folder in linked_folders:
-            dst_link = os.path.join(dst, linked_folder)
+        for dst_link in created_links:
             abs_path = os.path.realpath(dst_link)
             if not os.path.exists(abs_path):
+                base_path = os.path.dirname(dst_link)
                 os.remove(dst_link)
+                while base_path.startswith(dst):
+                    try:  # Take advantage that os.rmdir does not delete non-empty dirs
+                        os.rmdir(base_path)
+                    except OSError:
+                        break  # not empty
+                    base_path = os.path.dirname(base_path)
 
     @staticmethod
     def _copy_files(files, src, dst, keep_path, symlinks):


Test patch:
diff --git a/conans/test/integration/symlinks_test.py b/conans/test/integration/symlinks_test.py
--- a/conans/test/integration/symlinks_test.py
+++ b/conans/test/integration/symlinks_test.py
@@ -3,7 +3,7 @@
 import unittest
 
 from conans.test.utils.tools import TestClient, TestServer
-from conans.util.files import load, save
+from conans.util.files import load, save, mkdir
 from conans.model.ref import PackageReference, ConanFileReference
 
 conanfile = """
@@ -163,3 +163,76 @@ def upload_test(self):
         client.save({"conanfile.txt": test_conanfile}, clean_first=True)
         client.run("install conanfile.txt")
         self._check(client, ref, build=False)
+
+    def export_pattern_test(self):
+        conanfile = """from conans import ConanFile
+class ConanSymlink(ConanFile):
+    name = "ConanSymlink"
+    version = "3.0.0"
+    exports_sources = %s
+"""
+        for export_sources in ["['src/*', 'CMakeLists.txt']", "['*', '!*another_directory*']"]:
+            client = TestClient()
+            client.save({"conanfile.py": conanfile % export_sources,
+                         "src/main.cpp": "cpp fake content",
+                         "CMakeLists.txt": "cmake fake content",
+                         "another_directory/not_to_copy.txt": ""})
+            mkdir(os.path.join(client.current_folder, "another_other_directory"))
+            symlink_path = os.path.join(client.current_folder, "another_other_directory",
+                                        "another_directory")
+            symlinked_path = os.path.join(client.current_folder, "another_directory")
+            self.assertFalse(os.path.exists(symlink_path))
+            self.assertTrue(os.path.exists(symlinked_path))
+            os.symlink(symlinked_path, symlink_path)
+            client.run("export . danimtb/testing")
+            ref = ConanFileReference("ConanSymlink", "3.0.0", "danimtb", "testing")
+            export_sources = client.paths.export_sources(ref)
+            cache_other_dir = os.path.join(export_sources, "another_other_directory")
+            cache_src = os.path.join(export_sources, "src")
+            cache_main = os.path.join(cache_src, "main.cpp")
+            cache_cmake = os.path.join(export_sources, "CMakeLists.txt")
+            self.assertFalse(os.path.exists(cache_other_dir))
+            self.assertTrue(os.path.exists(cache_src))
+            self.assertTrue(os.path.exists(cache_main))
+            self.assertTrue(os.path.exists(cache_cmake))
+
+    def export_ignore_case_test(self):
+        conanfile = """from conans import ConanFile
+class ConanSymlink(ConanFile):
+    name = "ConanSymlink"
+    version = "3.0.0"
+    exports_sources = ["*"]
+    def package(self):
+        self.copy("*NOT_TO_COPY.TXT", ignore_case=%s)
+"""
+        client = TestClient()
+        client.save({"conanfile.py": conanfile % "False",
+                     "src/main.cpp": "cpp fake content",
+                     "CMakeLists.txt": "cmake fake content",
+                     "another_directory/not_to_copy.txt": ""})
+        mkdir(os.path.join(client.current_folder, "another_other_directory"))
+        symlink_path = os.path.join(client.current_folder, "another_other_directory",
+                                    "another_directory")
+        symlinked_path = os.path.join(client.current_folder, "another_directory")
+        self.assertFalse(os.path.exists(symlink_path))
+        self.assertTrue(os.path.exists(symlinked_path))
+        os.symlink(symlinked_path, symlink_path)
+        client.run("create . danimtb/testing")
+        ref = ConanFileReference("ConanSymlink", "3.0.0", "danimtb", "testing")
+        cache_file = os.path.join(client.paths.export_sources(ref), "another_directory",
+                                  "not_to_copy.txt")
+        self.assertTrue(os.path.exists(cache_file))
+        cache_other_dir = os.path.join(client.paths.export_sources(ref),
+                                       "another_other_directory")
+        self.assertTrue(os.path.exists(cache_other_dir))
+        pkg_ref = PackageReference(ref, "5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9")
+        package_file = os.path.join(client.paths.package(pkg_ref), "another_directory",
+                                    "not_to_copy.txt")
+        self.assertFalse(os.path.exists(package_file))
+        package_other_dir = os.path.join(client.paths.package(pkg_ref),
+                                         "another_other_directory")
+        self.assertFalse(os.path.exists(package_other_dir))
+        client.save({"conanfile.py": conanfile % "True"})
+        client.run("create . danimtb/testing")
+        self.assertTrue(os.path.exists(package_file))
+        self.assertTrue(os.path.exists(package_other_dir))