diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index 98d2a5e5cdf00e2..05031ff4e9889d7 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -373,13 +373,14 @@ ZipFile objects object was changed from ``'r'`` to ``'rb'``. -.. method:: ZipFile.extract(member, path=None, pwd=None) +.. method:: ZipFile.extract(member, path=None, pwd=None, extractor=None) Extract a member from the archive to the current working directory; *member* - must be its full name or a :class:`ZipInfo` object. Its file information is - extracted as accurately as possible. *path* specifies a different directory - to extract to. *member* can be a filename or a :class:`ZipInfo` object. - *pwd* is the password used for encrypted files as a :class:`bytes` object. + must be its full name or a :class:`ZipInfo` object. *path* specifies a + different directory to extract to. *pwd* is the password used for encrypted + files as a :class:`bytes` object. *extractor* is a custom + :ref:`extractor class ` that handles file attribute restoration + (defaults to :class:`ZipExtractorBase`, which restores no attributes). Returns the normalized path created (a directory or new file). @@ -393,6 +394,12 @@ ZipFile objects characters (``:``, ``<``, ``>``, ``|``, ``"``, ``?``, and ``*``) replaced by underscore (``_``). + .. note:: + + Use :meth:`extractall` when extracting multiple members. The + :meth:`extract` method may not restore all file attributes for the whole + directory hierarchy reliably. + .. versionchanged:: 3.6 Calling :meth:`extract` on a closed ZipFile will raise a :exc:`ValueError`. Previously, a :exc:`RuntimeError` was raised. @@ -400,13 +407,18 @@ ZipFile objects .. versionchanged:: 3.6.2 The *path* parameter accepts a :term:`path-like object`. + .. versionchanged:: next + Added the *extractor* parameter. + -.. method:: ZipFile.extractall(path=None, members=None, pwd=None) +.. method:: ZipFile.extractall(path=None, members=None, pwd=None, extractor=None) Extract all members from the archive to the current working directory. *path* specifies a different directory to extract to. *members* is optional and must be a subset of the list returned by :meth:`namelist`. *pwd* is the password - used for encrypted files as a :class:`bytes` object. + used for encrypted files as a :class:`bytes` object. *extractor* is a custom + :ref:`extractor class ` that handles file attribute restoration + (defaults to :class:`ZipExtractorBase`, which restores no attributes). .. warning:: @@ -423,6 +435,9 @@ ZipFile objects .. versionchanged:: 3.6.2 The *path* parameter accepts a :term:`path-like object`. + .. versionchanged:: next + Added the *extractor* parameter. + .. method:: ZipFile.printdir() @@ -1041,6 +1056,74 @@ Instances have the following methods and attributes: Size of the uncompressed file. +.. _extractors: + +Extractors +---------- + +An extractor class can be passed to :meth:`ZipFile.extract` or +:meth:`ZipFile.extractall` to handle file attribute restoration. + +To create a custom extractor, subclass :class:`ZipExtractorBase` and implement +the :meth:`~ZipExtractorBase.restore_attributes` method. Here is an example +that ignores errors when restoring file attributes: + +.. code-block:: python + + class SafeZipExtractorTimeMode(zipfile.ZipExtractorTimeMode): + def restore_attributes(self, targetpath, zinfo): + try: + super().restore_attributes(targetpath, zinfo) + except (OSError, OverflowError, ValueError): + pass + + with zipfile.ZipFile('spam.zip') as myzip: + myzip.extractall('eggs', extractor=SafeZipExtractorTimeMode) + + +.. class:: ZipExtractorBase(archive, path=None, pwd=None) + + The base extractor class that restores no file attributes. + + .. method:: restore_attributes(targetpath, zinfo) + + Called for every extracted member *zinfo* at *targetpath* to restore + its attributes. This method does nothing by default. Subclasses + should override this method to customize attribute restoration. + + .. method:: finalize(exc_type=None, exc_value=None, traceback=None) + + Called after all members have been extracted (or when an exception + occurs) to restore attributes for extracted directories in reverse + (bottom-up) order. This prevents issues where extracting contents into + a directory resets its modification time or fails if it's not writable. + Subclasses can override this method to customize cleanup or error + recovery logic. + + +.. class:: ZipExtractorTime(archive, path=None, pwd=None) + + A subclass of :class:`ZipExtractorBase` that restores file mtime and atime. + + +.. class:: ZipExtractorTimeMode(archive, path=None, pwd=None) + + A subclass of :class:`ZipExtractorTime` that restores file mtime, atime, and + all mode bits (``0o7777``). + + +.. class:: ZipExtractorTimeModeSafe(archive, path=None, pwd=None) + + A subclass of :class:`ZipExtractorTimeMode` that restores file mtime, atime, + and safe file mode bits (``0o777``). + + +.. class:: ZipExtractorTimeModeX(archive, path=None, pwd=None) + + A subclass of :class:`ZipExtractorTimeMode` that restores file mtime, atime, + and executable bits (``0o111``). + + .. _zipfile-commandline: .. program:: zipfile diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index cd498ba13e6f461..05f0d3997372cda 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -3948,6 +3948,144 @@ def _test_extract_hackers_arcnames(self, hacknames): unlink(TESTFN2) +class ExtractorTests(unittest.TestCase): + test_data = [ + ('folder1/', b'', (1990, 1, 2, 0, 0, 0), (0o47777 << 16) | 0x10), + ('folder1/file1.txt', b'qawsedrftg', (1990, 2, 2, 0, 0, 0), 0o106642 << 16), + ('implicit_folder/file2.txt', b'azsxdcfvgb', (1990, 3, 2, 0, 0, 0), 0o100246 << 16), + ] + + @classmethod + def setUpClass(cls): + os.mkdir(TESTFNDIR) + cls.dmode = os.stat(TESTFNDIR).st_mode + rmtree(TESTFNDIR) + + with open(TESTFN, 'wb'): + pass + cls.fmode = os.stat(TESTFN).st_mode + os.unlink(TESTFN) + + def setUp(self): + os.mkdir(TESTFNDIR) + + def tearDown(self): + os.unlink(TESTFN) + rmtree(TESTFNDIR) + + def make_test_archive(self, data): + with zipfile.ZipFile(TESTFN, 'w') as zipfp: + for filename, content, dt, ext_attr in data: + zinfo = zipfile.ZipInfo(filename, dt) + zinfo.external_attr = ext_attr + zipfp.writestr(zinfo, content) + + def test_extract_default(self): + """Should not restore attributes by default.""" + self.make_test_archive(self.test_data) + with zipfile.ZipFile(TESTFN) as zipfp: + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename): + zipfp.extract(filename, TESTFNDIR) + now = time.time() + outfile = os.path.join(TESTFNDIR, filename) + mtime = os.path.getmtime(outfile) + self.assertAlmostEqual(mtime, now, delta=5) + + def test_extract_time(self): + """Should restore mtime/atime.""" + for extractor_cls, mask in ( + (zipfile.ZipExtractorTime, 0), + (zipfile.ZipExtractorTimeModeX, 0o111), + (zipfile.ZipExtractorTimeModeSafe, 0o777), + (zipfile.ZipExtractorTimeMode, 0o7777), + ): + rmtree(TESTFNDIR) + self.make_test_archive(self.test_data) + with zipfile.ZipFile(TESTFN) as zipfp: + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename, extractor=extractor_cls): + zipfp.extract(filename, TESTFNDIR, extractor=extractor_cls) + outfile = os.path.join(TESTFNDIR, filename) + mtuple = time.localtime(os.path.getmtime(outfile))[:6] + atuple = time.localtime(os.path.getatime(outfile))[:6] + self.assertEqual(mtuple, dt) + self.assertEqual(atuple, dt) + + def test_extract_all_default(self): + """Should not restore attributes by default.""" + self.make_test_archive(self.test_data) + now = time.time() + with zipfile.ZipFile(TESTFN) as zipfp: + zipfp.extractall(TESTFNDIR) + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename): + outfile = os.path.join(TESTFNDIR, filename) + mtime = os.path.getmtime(outfile) + self.assertAlmostEqual(mtime, now, delta=5) + + def test_extract_all_time(self): + """Should restore mtime/atime.""" + for extractor_cls, mask in ( + (zipfile.ZipExtractorTime, 0), + (zipfile.ZipExtractorTimeModeX, 0o111), + (zipfile.ZipExtractorTimeModeSafe, 0o777), + (zipfile.ZipExtractorTimeMode, 0o7777), + ): + rmtree(TESTFNDIR) + self.make_test_archive(self.test_data) + with zipfile.ZipFile(TESTFN) as zipfp: + zipfp.extractall(TESTFNDIR, extractor=extractor_cls) + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename, extractor=extractor_cls): + outfile = os.path.join(TESTFNDIR, filename) + mtuple = time.localtime(os.path.getmtime(outfile))[:6] + atuple = time.localtime(os.path.getatime(outfile))[:6] + self.assertEqual(mtuple, dt) + self.assertEqual(atuple, dt) + + @unittest.skipIf(sys.platform == 'win32' or sys.platform == 'wasi', 'Requires file permissions') + def test_extract_all_mode(self): + """Should restore (masked) mode.""" + for extractor_cls, mask in ( + (zipfile.ZipExtractorTime, 0), + (zipfile.ZipExtractorTimeModeX, 0o111), + (zipfile.ZipExtractorTimeModeSafe, 0o777), + (zipfile.ZipExtractorTimeMode, 0o7777), + ): + rmtree(TESTFNDIR) + self.make_test_archive(self.test_data) + with zipfile.ZipFile(TESTFN) as zipfp: + zipfp.extractall(TESTFNDIR, extractor=extractor_cls) + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename, extractor=extractor_cls): + outfile = os.path.join(TESTFNDIR, filename) + default_mode = (self.dmode if os.path.isdir(outfile) else self.fmode) + expected_mode = (default_mode & ~mask | ((ext_attr >> 16) & mask)) + mode = os.stat(outfile).st_mode + self.assertEqual(oct(mode), oct(expected_mode)) + + @unittest.skipIf(sys.platform == 'win32' or sys.platform == 'wasi', 'Requires file permissions') + def test_extract_all_hierarchy(self): + """Should safely extract contents into a non-writable directory.""" + test_data = [ + ('folder1/', b'', (1990, 1, 2, 0, 0, 0), (0o40100 << 16) | 0x10), + ('folder1/file1.txt', b'qawsedrftg', (1990, 2, 2, 0, 0, 0), 0o100000 << 16), + ('folder1/file2.txt', b'azsxdcfvgb', (1990, 3, 2, 0, 0, 0), 0o100777 << 16), + ] + mask = 0o7777 + self.make_test_archive(self.test_data) + with zipfile.ZipFile(TESTFN) as zipfp: + zipfp.extractall(TESTFNDIR, extractor=zipfile.ZipExtractorTimeMode) + for filename, content, dt, ext_attr in self.test_data: + with self.subTest(filename=filename): + outfile = os.path.join(TESTFNDIR, filename) + default_mode = (self.dmode if os.path.isdir(outfile) else self.fmode) + expected_mode = (default_mode & ~mask | ((ext_attr >> 16) & mask)) + mode = os.stat(outfile).st_mode + self.assertEqual(oct(mode), oct(expected_mode)) + + class OverwriteTests(archiver_tests.OverwriteTests, unittest.TestCase): testdir = TESTFN diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 418933a2e8d9e87..b017af201a1eab3 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -38,7 +38,9 @@ __all__ = ["BadZipFile", "BadZipfile", "error", "ZIP_STORED", "ZIP_DEFLATED", "ZIP_BZIP2", "ZIP_LZMA", "ZIP_ZSTANDARD", "is_zipfile", "ZipInfo", "ZipFile", "PyZipFile", - "LargeZipFile", "Path"] + "ZipExtractorBase", "ZipExtractorTime", "ZipExtractorTimeMode", + "ZipExtractorTimeModeSafe", "ZipExtractorTimeModeX", "LargeZipFile", + "Path"] class BadZipFile(Exception): pass @@ -1864,6 +1866,91 @@ def _copy_bytes(self, fp, old_offset, new_offset, size): read_size += len(data) +class ZipExtractorBase: + """The base extractor class that does not restore attributes.""" + debug = 0 + allowed_mode = 0o7777 + + def __init__(self, archive, path=None, pwd=None): + self.archive = archive + if path is None: + self.target_path = os.getcwd() + else: + self.target_path = os.fspath(path) + self.pwd = pwd + self._dirs = {} + + def _debug(self, level, *msg, file=sys.stderr): + if level <= self.debug: + print(*msg, file=file) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.finalize(exc_type, exc_value, traceback) + + def __call__(self, zinfo): + targetpath = self.archive._extract_member(zinfo, self.target_path, self.pwd) + + # Delay restoring attributes for directories since permissions + # can interfere with extraction and extracting contents can + # reset mtime. + if zinfo.is_dir(): + self._dirs[zinfo] = targetpath + else: + self.restore_attributes(targetpath, zinfo) + + return targetpath + + def finalize(self, exc_type=None, exc_value=None, traceback=None): + # restore attributes for directories from bottom to top + items = sorted(self._dirs.items(), key=lambda i: i[0].filename, reverse=True) + for zinfo, targetpath in items: + # skip if directory missing + if not os.path.isdir(targetpath): + continue + + self.restore_attributes(targetpath, zinfo) + + def restore_attributes(self, targetpath, zinfo): + pass + + def utime(self, targetpath, zinfo): + dt = time.mktime(zinfo.date_time + (0, 0, -1)) + os.utime(targetpath, (dt, dt)) + + def chmod(self, targetpath, zinfo): + mode = (zinfo.external_attr >> 16) & self.allowed_mode + mode = os.stat(targetpath).st_mode & ~self.allowed_mode | mode + os.chmod(targetpath, mode) + +class ZipExtractorTime(ZipExtractorBase): + """Restores time (if platform supports).""" + def restore_attributes(self, targetpath, zinfo): + try: + self.utime(targetpath, zinfo) + except OSError as exc: + self._debug(1, f'zipfile: {exc}') + +class ZipExtractorTimeMode(ZipExtractorTime): + """Restores time and all mode (if platform supports).""" + def restore_attributes(self, targetpath, zinfo): + try: + self.utime(targetpath, zinfo) + self.chmod(targetpath, zinfo) + except OSError as exc: + self._debug(1, f'zipfile: {exc}') + +class ZipExtractorTimeModeSafe(ZipExtractorTimeMode): + """Restores time and safe mode (if platform supports).""" + allowed_mode = 0o777 + +class ZipExtractorTimeModeX(ZipExtractorTimeMode): + """Restores time and x mode (if platform supports).""" + allowed_mode = 0o111 + + class ZipFile: """ Class with methods to open, read, write, close, list zip files. @@ -2313,37 +2400,36 @@ def _open_to_write(self, zinfo, force_zip64=False): self._writing = True return _ZipWriteFile(self, zinfo, zip64) - def extract(self, member, path=None, pwd=None): + def extract(self, member, path=None, pwd=None, extractor=None): """Extract a member from the archive to the current working directory, using its full name. Its file information is extracted as accurately as possible. 'member' may be a filename or a ZipInfo object. You can specify a different directory using 'path'. You can specify the - password to decrypt the file using 'pwd'. + password to decrypt the file using 'pwd'. 'extractor' specifies a + custom extractor class. """ - if path is None: - path = os.getcwd() - else: - path = os.fspath(path) + if extractor is None: + extractor = ZipExtractorBase + zinfo = member if isinstance(member, ZipInfo) else self.getinfo(member) + with extractor(self, path, pwd=pwd) as extr: + return extr(zinfo) - return self._extract_member(member, path, pwd) - - def extractall(self, path=None, members=None, pwd=None): + def extractall(self, path=None, members=None, pwd=None, extractor=None): """Extract all members from the archive to the current working directory. 'path' specifies a different directory to extract to. 'members' is optional and must be a subset of the list returned by namelist(). You can specify the password to decrypt all files - using 'pwd'. + using 'pwd'. 'extractor' specifies a custom extractor class. """ if members is None: members = self.namelist() + if extractor is None: + extractor = ZipExtractorBase - if path is None: - path = os.getcwd() - else: - path = os.fspath(path) - - for zipinfo in members: - self._extract_member(zipinfo, path, pwd) + zinfos = [m if isinstance(m, ZipInfo) else self.getinfo(m) for m in members] + with extractor(self, path, pwd=pwd) as extr: + for zinfo in zinfos: + extr(zinfo) def remove(self, zinfo_or_arcname): """Remove a member from the archive.""" diff --git a/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76351.1DhL-c.rst b/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76351.1DhL-c.rst new file mode 100644 index 000000000000000..ed0adb4edf2ba9c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-26-04-46-11.gh-issue-76351.1DhL-c.rst @@ -0,0 +1 @@ +Add ``extractor`` parameter for :meth:`zipfile.ZipFile.extract` and :meth:`zipfile.ZipFile.extractall` to support customizable file attributes restoration when extracting files from a ZIP archive.