pkg_tar: Fix #948: Don't add duplicate directory members to tar file when a symlink with the same path exists (#949)

Fixes #948.

I've modified the deduplication logic to account for the fact that
directories must not have the same path as other types of files.
Previously, the paths `/lib` and `/lib/` would be treated differently.

Two test cases are included: one for symlinks preceding directories, and
one for directories preceding symlinks. In both cases, the first member
is preserved and the second member with the same path is discarded as a
duplicate.
diff --git a/pkg/private/tar/tar_writer.py b/pkg/private/tar/tar_writer.py
index 6c0d926..b81f6db 100644
--- a/pkg/private/tar/tar_writer.py
+++ b/pkg/private/tar/tar_writer.py
@@ -35,6 +35,18 @@
 
 _DEBUG_VERBOSITY = 0
 
+TARFILE_MEMBER_TYPE_TO_STR = {
+    b"0": "REGTYPE",
+    b"\0": "AREGTYPE",
+    b"1": "LNKTYPE",
+    b"2": "SYMTYPE",
+    b"3": "CHRTYPE",
+    b"4": "BLKTYPE",
+    b"5": "DIRTYPE",
+    b"6": "FIFOTYPE",
+    b"7": "CONTTYPE",
+}
+
 
 class TarFileWriter(object):
   """A wrapper to write tar files."""
@@ -103,13 +115,7 @@
 
     self.tar = tarfile.open(name=name, mode=mode, fileobj=self.fileobj,
                             format=tarfile.GNU_FORMAT)
-    self.members = set()
-    self.directories = set()
-    # Preseed the added directory list with things we should not add. If we
-    # some day need to allow '.' or '/' as an explicit member of the archive,
-    # we can adjust that here based on the setting of root_directory.
-    self.directories.add('/')
-    self.directories.add('./')
+    self.existing_members = {}
     self.create_parents = create_parents
     self.allow_dups_from_deps = allow_dups_from_deps
 
@@ -119,28 +125,43 @@
   def __exit__(self, t, v, traceback):
     self.close()
 
-  def _have_added(self, path):
-    """Have we added this file before."""
-    return (path in self.members) or (path in self.directories)
+  def _existing_member_type(self, path):
+    """Retrieve an existing tar file member's type if we have added it previously,
+    return None otherwise."""
+    # Things we should not add.
+    # If we some day need to allow '.' or '/' as an explicit member of the archive,
+    # we can adjust that here based on the setting of root_directory.
+    if path == '/' or path == './':
+      return tarfile.DIRTYPE
+
+    normalized_path = path.rstrip("/")
+    return self.existing_members.get(normalized_path, None)
 
   def _addfile(self, info, fileobj=None):
     """Add a file in the tar file if there is no conflict."""
     if info.type == tarfile.DIRTYPE:
-      # Enforce the ending / for directories so we correctly deduplicate.
+      # Enforce the ending / for directories.
       if not info.name.endswith('/'):
         info.name += '/'
-    if not self.allow_dups_from_deps and self._have_added(info.name):
+    existing_member_type = self._existing_member_type(info.name)
+    if not self.allow_dups_from_deps and existing_member_type is not None:
       # Directories with different contents should get merged without warnings.
       # If they have overlapping content, the warning will be on their duplicate *files* instead
       if info.type != tarfile.DIRTYPE:
         print('Duplicate file in archive: %s, '
               'picking first occurrence' % info.name)
+      # Directories that shadow
+      elif existing_member_type != tarfile.DIRTYPE and existing_member_type != tarfile.SYMTYPE:
+        print('Directory shadows a member of type %s in archive: %s, '
+              'picking first occurrence' % (TARFILE_MEMBER_TYPE_TO_STR.get(
+                  existing_member_type, "UNKNOWN"), info.name))
+
       return
 
     self.tar.addfile(info, fileobj)
-    self.members.add(info.name)
-    if info.type == tarfile.DIRTYPE:
-      self.directories.add(info.name)
+    # Strip the trailing slash from the path so that we can detect when, for example, we are
+    # trying to overwrite a symbolic link with a directory.
+    self.existing_members[info.name.rstrip("/")] = info.type
 
   def add_directory_path(self,
                          path,
@@ -182,7 +203,8 @@
     for next_level in dirs[0:-1]:
       parent_path = parent_path + next_level + '/'
 
-      if self.create_parents and not self._have_added(parent_path):
+      if self.create_parents and self._existing_member_type(
+          parent_path) is None:
         self.add_directory_path(
           parent_path,
           uid=uid,
@@ -224,7 +246,8 @@
       return
     if name == '.':
       return
-    if not self.allow_dups_from_deps and name in self.members:
+    if not self.allow_dups_from_deps and self._existing_member_type(
+        name) is not None:
       return
 
     if mtime is None:
diff --git a/tests/tar/tar_writer_test.py b/tests/tar/tar_writer_test.py
index e7d9c59..fcc5889 100644
--- a/tests/tar/tar_writer_test.py
+++ b/tests/tar/tar_writer_test.py
@@ -162,7 +162,7 @@
           "rules_pkg/tests/testdata/tar_test.tar")
       f.add_tar(input_tar_path)
       input_tar = tarfile.open(input_tar_path, "r")
-      for file_name in f.members:
+      for file_name in f.existing_members.keys():
         input_file = input_tar.getmember(file_name)
         output_file = f.tar.getmember(file_name)
         self.assertEqual(input_file.mtime, output_file.mtime)
@@ -300,6 +300,31 @@
 
     self.assertTarFileContent(self.tempfile, expected_content)
 
+  def testDirectoryDoesNotShadowSymlink(self):
+    with tar_writer.TarFileWriter(self.tempfile, create_parents=True, allow_dups_from_deps=False) as f:
+      f.add_file("target_dir", tarfile.DIRTYPE)
+      f.add_file("symlink", tarfile.SYMTYPE, link="target_dir")
+      f.add_file("symlink", tarfile.DIRTYPE)
+      f.add_file('symlink/a', content="q")
+    content = [
+      {"name": "target_dir", "type": tarfile.DIRTYPE},
+      {"name": "symlink", "type": tarfile.SYMTYPE},
+      {"name": "symlink/a", "type": tarfile.REGTYPE},
+    ]
+    self.assertTarFileContent(self.tempfile, content)
+
+  def testSymlinkDoesNotShadowDirectory(self):
+    with tar_writer.TarFileWriter(self.tempfile, create_parents=True, allow_dups_from_deps=False) as f:
+      f.add_file("target_dir", tarfile.DIRTYPE)
+      f.add_file("not_a_symlink", tarfile.DIRTYPE)
+      f.add_file("not_a_symlink", tarfile.SYMTYPE, link="target_dir")
+      f.add_file('not_a_symlink/a', content="q")
+    content = [
+      {"name": "target_dir", "type": tarfile.DIRTYPE},
+      {"name": "not_a_symlink", "type": tarfile.DIRTYPE},
+      {"name": "not_a_symlink/a", "type": tarfile.REGTYPE},
+    ]
+    self.assertTarFileContent(self.tempfile, content)
 
 
 if __name__ == "__main__":