diff --git a/README.rst b/README.rst index 2a21841..43f402c 100644 --- a/README.rst +++ b/README.rst @@ -114,6 +114,14 @@ and the optional third argument is the compression format (called “filter” i libarchive). The acceptable values are listed in ``libarchive.ffi.WRITE_FORMATS`` and ``libarchive.ffi.WRITE_FILTERS``. +Symbolic links +~~~~~~~~~~~~~~ + +By default, libarchive preserves symbolic links. If you want it to resolve the +links and archive the files they point to instead, pass ``symlink_mode='logical'`` +when calling the ``add_files`` method. If you do that, an ``ArchiveError`` +exception will be raised when a symbolic link points to a nonexistent file. + File metadata codecs -------------------- diff --git a/libarchive/ffi.py b/libarchive/ffi.py index 1fc321a..172fe87 100644 --- a/libarchive/ffi.py +++ b/libarchive/ffi.py @@ -286,6 +286,9 @@ def get_write_filter_function(filter_name): ffi('read_disk_open', [c_archive_p, c_char_p], c_int, check_int) ffi('read_disk_open_w', [c_archive_p, c_wchar_p], c_int, check_int) ffi('read_disk_descend', [c_archive_p], c_int, check_int) +ffi('read_disk_set_symlink_hybrid', [c_archive_p], c_int, check_int) +ffi('read_disk_set_symlink_logical', [c_archive_p], c_int, check_int) +ffi('read_disk_set_symlink_physical', [c_archive_p], c_int, check_int) # archive_read_data diff --git a/libarchive/write.py b/libarchive/write.py index 3b6caba..782e0f7 100644 --- a/libarchive/write.py +++ b/libarchive/write.py @@ -46,7 +46,7 @@ def add_entries(self, entries): def add_files( self, *paths, flags=0, lookup=False, pathname=None, recursive=True, - **attributes + symlink_mode=None, **attributes ): """Read files through the OS and add them to the archive. @@ -63,6 +63,9 @@ def add_files( recursive (bool): when False, if a path in `paths` is a directory, only the directory itself is added. + symlink_mode (Literal['hybrid', 'logical', 'physical'] | None): + how symbolic links should be handled; see `man archive_read_disk` + for meanings attributes (dict): passed to `ArchiveEntry.modify()` Raises: @@ -75,10 +78,23 @@ def add_files( if block_size <= 0: block_size = 10240 # pragma: no cover + set_symlink_mode = None + if symlink_mode: + try: + set_symlink_mode = getattr( + ffi, f'read_disk_set_symlink_{symlink_mode}' + ) + except AttributeError: + raise ValueError( + f"symlink_mode value {symlink_mode!r} is invalid" + ) from None + entry = ArchiveEntry(header_codec=self.header_codec) entry_p = entry._entry_p for path in paths: with new_archive_read_disk(path, flags, lookup) as read_p: + if set_symlink_mode: + set_symlink_mode(read_p) while 1: r = read_next_header2(read_p, entry_p) if r == ARCHIVE_EOF: diff --git a/tests/test_rwx.py b/tests/test_rwx.py index 77c16fc..bac04eb 100644 --- a/tests/test_rwx.py +++ b/tests/test_rwx.py @@ -2,6 +2,7 @@ import io import json +import os import libarchive from libarchive.entry import format_time @@ -181,3 +182,32 @@ def write_callback(data): ) assert archive_entry.uid == 1000 assert archive_entry.gid == 1000 + + +def test_symlinks(tmpdir): + os.chdir(tmpdir) + with open('empty', 'w'): + pass + with open('unreadable', 'w') as f: + f.write('secret') + os.chmod('unreadable', 0) + + os.symlink('empty', 'symlink-to-empty') + os.symlink('unreadable', 'symlink-to-unreadable') + + with libarchive.file_writer('archive.tar', 'gnutar') as archive: + archive.add_files('symlink-to-empty', symlink_mode='hybrid') + with pytest.raises(libarchive.ArchiveError): + archive.add_files('symlink-to-unreadable', symlink_mode='logical') + archive.add_files('symlink-to-unreadable', symlink_mode='physical') + + with libarchive.file_reader('archive.tar') as archive: + entries = iter(archive) + e1 = next(entries) + assert e1.pathname == 'symlink-to-empty' + assert e1.isreg + assert e1.size == 0 + e2 = next(entries) + assert e2.pathname == 'symlink-to-unreadable' + assert e2.issym + assert e2.linkpath == 'unreadable'