Repository: conan-io/conan

Issue:
exports_sources fails to properly handle symlinks
To help us debug your issue please explain:

- [x] I've read the [CONTRIBUTING guide](https://github.com/conan-io/conan/blob/develop/.github/CONTRIBUTING.md).
- [x] I've specified the Conan version, operating system version and any tool that can be relevant.
- [x] I've explained the steps to reproduce the error or the motivation/use case of the question/suggestion.

OS: macOS 10.14.5
Conan: 1.16.0 (installed with pip into virtualenv, but also fails with 1.16.0 from Brew). Works with 1.15.1.
Python: 3.7.3 (installed from MacPorts)

`conanfile.py` has `exports_sources=src/*`, there is `src/foo.framework` with symlinks:
```
.
├── Headers -> Versions/Current/Headers
├── Resources -> Versions/Current/Resources
├── Versions
│   ├── A
│   │   ├── Headers
│   │   │   └── foo.h
│   │   ├── Resources
│   │   │   ├── Info.plist
│   │   │   └── en.lproj
│   │   │       └── InfoPlist.strings
│   │   └── foo
│   └── Current -> A
└── foo -> Versions/Current/foo
```
`conan create . test/test` fails with the following error:
```
% conan create . test/test                                                                                                                                                                                                                                          master
Exporting package recipe
symlinks/1.0.0@test/test exports_sources: Copied 1 '.txt' file: CMakeLists.txt
symlinks/1.0.0@test/test exports_sources: Copied 1 '.cpp' file: hello.cpp
symlinks/1.0.0@test/test exports_sources: Copied 2 '.h' files: hello.h, foo.h
symlinks/1.0.0@test/test exports_sources: Copied 2 files: foo, foo
symlinks/1.0.0@test/test exports_sources: Copied 1 '.plist' file: Info.plist
symlinks/1.0.0@test/test exports_sources: Copied 1 '.strings' file: InfoPlist.strings
symlinks/1.0.0@test/test: The stored package has not changed
symlinks/1.0.0@test/test: Source folder is corrupted, forcing removal
Configuration:
[settings]
[options]
[build_requires]
[env]

symlinks/1.0.0@test/test: Forced build from source
Installing package: symlinks/1.0.0@test/test
Requirements
    symlinks/1.0.0@test/test from local cache - Cache
Packages
    symlinks/1.0.0@test/test:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 - Build

symlinks/1.0.0@test/test: WARN: Build folder is dirty, removing it: /Users/paul/.conan/data/symlinks/1.0.0/test/test/build/5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9
symlinks/1.0.0@test/test: WARN: Trying to remove corrupted source folder
symlinks/1.0.0@test/test: WARN: This can take a while for big packages
symlinks/1.0.0@test/test: Configuring sources in /Users/paul/.conan/data/symlinks/1.0.0/test/test/source
ERROR: [Errno 17] File exists: 'Versions/Current/Resources' -> '/Users/paul/.conan/data/symlinks/1.0.0/test/test/source/src/foo.framework/Resources'
```

Sample project: [conan_symlinks](https://github.com/Paultergeist/conan_symlinks)


Discussion:
Please @jgsogo have a look at this, it could be a regression from latest changes in 1.16 related to the SCM and symlinks

Resolving patch:
diff --git a/conans/client/source.py b/conans/client/source.py
--- a/conans/client/source.py
+++ b/conans/client/source.py
@@ -5,7 +5,6 @@
 
 from conans.client import tools
 from conans.client.cmd.export import export_recipe, export_source
-from conans.client.file_copier import FileCopier
 from conans.errors import ConanException, ConanExceptionInUserConanfileMethod, \
     conanfile_exception_formatter
 from conans.model.conan_file import get_env_context_manager
@@ -40,7 +39,7 @@ def complete_recipe_sources(remote_manager, cache, conanfile, ref, remotes):
     remote_manager.get_recipe_sources(ref, export_path, sources_folder, current_remote)
 
 
-def merge_directories(src, dst, excluded=None, symlinks=True):
+def merge_directories(src, dst, excluded=None):
     src = os.path.normpath(src)
     dst = os.path.normpath(dst)
     excluded = excluded or []
@@ -54,16 +53,30 @@ def is_excluded(origin_path):
             return True
         return False
 
-    linked_folders = []
-    for src_dir, dirs, files in walk(src, followlinks=True):
+    def link_to_rel(pointer_src):
+        linkto = os.readlink(pointer_src)
+        if not os.path.isabs(linkto):
+            linkto = os.path.join(os.path.dirname(pointer_src), linkto)
+
+        # Check if it is outside the sources
+        out_of_source = os.path.relpath(linkto, os.path.realpath(src)).startswith(".")
+        if out_of_source:
+            # May warn about out of sources symlink
+            return
+
+        # Create the symlink
+        linkto_rel = os.path.relpath(linkto, os.path.dirname(pointer_src))
+        pointer_dst = os.path.normpath(os.path.join(dst, os.path.relpath(pointer_src, src)))
+        os.symlink(linkto_rel, pointer_dst)
 
+    for src_dir, dirs, files in walk(src, followlinks=True):
         if is_excluded(src_dir):
             dirs[:] = []
             continue
 
         if os.path.islink(src_dir):
-            rel_link = os.path.relpath(src_dir, src)
-            linked_folders.append(rel_link)
+            link_to_rel(src_dir)
+            dirs[:] = []  # Do not enter subdirectories
             continue
 
         # Overwriting the dirs will prevents walk to get into them
@@ -75,14 +88,11 @@ def is_excluded(origin_path):
         for file_ in files:
             src_file = os.path.join(src_dir, file_)
             dst_file = os.path.join(dst_dir, file_)
-            if os.path.islink(src_file) and symlinks:
-                linkto = os.readlink(src_file)
-                os.symlink(linkto, dst_file)
+            if os.path.islink(src_file):
+                link_to_rel(src_file)
             else:
                 shutil.copy2(src_file, dst_file)
 
-    FileCopier.link_folders(src, dst, linked_folders)
-
 
 def config_source_local(src_folder, conanfile, conanfile_path, hook_manager):
     """ Entry point for the "conan source" command.


Test patch:
diff --git a/conans/test/functional/configuration/skip_broken_symlinks.py b/conans/test/functional/configuration/skip_broken_symlinks.py
--- a/conans/test/functional/configuration/skip_broken_symlinks.py
+++ b/conans/test/functional/configuration/skip_broken_symlinks.py
@@ -1,14 +1,16 @@
 import os
 import platform
+import textwrap
 import unittest
 
 from conans.model.ref import ConanFileReference
+from conans.test.utils.tools import TestClient
 from conans.test.utils.tools import TestServer, TurboTestClient
 
 
+@unittest.skipIf(platform.system() == "Windows", "Better to test only in NIX the symlinks")
 class TestSkipBrokenSymlinks(unittest.TestCase):
 
-    @unittest.skipIf(platform.system() == "Windows", "Better to test only in NIX the symlinks")
     def test_package_broken_symlinks(self):
         server = TestServer()
         client = TurboTestClient(servers={"default": server})
@@ -53,5 +55,27 @@ def package(self):
         client2.run("install {}".format(ref))
         self.assertIn("Downloaded package", client2.out)
 
+    def test_broken_in_local_sources(self):
+        conanfile = textwrap.dedent("""
+            from conans import ConanFile, CMake
 
+            class SymlinksConan(ConanFile):
+                name = "symlinks"
+                version = "1.0.0"
+                exports_sources = "src/*"
+            """)
 
+        t = TestClient()
+        t.save({'conanfile.py': conanfile, 'src/file': "content"})
+
+        # Create a broken symlink
+        broken_symlink = os.path.join(t.current_folder, 'src', 'link')
+        os.symlink('not-existing', broken_symlink)
+
+        # Check the bronken symlink locally
+        self.assertTrue(os.path.islink(broken_symlink))
+        self.assertFalse(os.path.exists(broken_symlink))
+        self.assertFalse(os.path.exists(os.path.realpath(broken_symlink)))
+
+        t.run("export . user/channel", assert_error=True)
+        self.assertIn("ERROR: The file is a broken symlink", t.out)
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
@@ -280,3 +280,46 @@ def build(self):
         bf_symlink = os.path.join(bf, "debug")
         self.assertTrue(os.path.islink(bf_symlink))
         self.assertEqual(os.path.realpath(bf_symlink), os.path.join(bf, "release"))
+
+
+@unittest.skipUnless(platform.system() != "Windows", "Requires Symlinks")
+class SymlinkExportSources(unittest.TestCase):
+    conanfile = textwrap.dedent("""
+        from conans import ConanFile, CMake
+
+        class SymlinksConan(ConanFile):
+            name = "symlinks"
+            version = "1.0.0"
+            exports_sources = "src/*"
+        """)
+
+    def test_create_source(self):
+        # Reproduces issue: https://github.com/conan-io/conan/issues/5329
+        t = TestClient()
+        relpath_v1 = os.path.join('src', 'framework', 'Versions', 'v1')
+        t.save({'conanfile.py': self.conanfile,
+                os.path.join(relpath_v1, 'headers', 'content'): "whatever",
+                os.path.join(relpath_v1, 'file'): "content"})
+
+        # Add two levels of symlinks
+        os.symlink('v1', os.path.join(t.current_folder, 'src', 'framework', 'Versions', 'Current'))
+        os.symlink('Versions/Current/headers',
+                   os.path.join(t.current_folder, 'src', 'framework', 'headers'))
+        os.symlink('Versions/Current/file',
+                   os.path.join(t.current_folder, 'src', 'framework', 'file'))
+
+        # Check that things are in place (locally): file exists and points to local directory
+        relpath_content = os.path.join('src', 'framework', 'headers', 'content')
+        local_content = os.path.join(t.current_folder, relpath_content)
+        self.assertTrue(os.path.exists(local_content))
+        self.assertEqual(os.path.realpath(local_content),
+                         os.path.join(t.current_folder, relpath_v1, 'headers', 'content'))
+
+        t.run("create . user/channel")
+
+        # Check that things are in place (in the cache): exists and points to 'source' directory
+        layout = t.cache.package_layout(ConanFileReference.loads("symlinks/1.0.0@user/channel"))
+        cache_content = os.path.join(layout.source(), relpath_content)
+        self.assertTrue(os.path.exists(cache_content))
+        self.assertEqual(os.path.realpath(cache_content),
+                         os.path.join(layout.source(), relpath_v1, 'headers', 'content'))
diff --git a/conans/test/unittests/client/source/test_run_scm.py b/conans/test/unittests/client/source/test_run_scm.py
--- a/conans/test/unittests/client/source/test_run_scm.py
+++ b/conans/test/unittests/client/source/test_run_scm.py
@@ -27,7 +27,7 @@ def test_in_cache_with_local_sources(self):
         conanfile.scm = {'type': 'git', 'url': 'auto', 'revision': 'auto'}
 
         # Mock functions called from inside _run_scm (tests will be here)
-        def merge_directories(src, dst, excluded=None, symlinks=True):
+        def merge_directories(src, dst, excluded=None):
             self.assertEqual(src, local_sources_path)
             self.assertEqual(dst, self.src_folder)
 
@@ -87,7 +87,7 @@ def test_user_space_with_local_sources(self):
         conanfile.scm = {'type': 'git', 'url': 'auto', 'revision': 'auto'}
 
         # Mock functions called from inside _run_scm (tests will be here)
-        def merge_directories(src, dst, excluded=None, symlinks=True):
+        def merge_directories(src, dst, excluded=None):
             src = os.path.normpath(src)
             dst = os.path.normpath(dst)
             self.assertEqual(src.replace('\\', '/'), local_sources_path)