Skip to content

gh-59999: Add option to preserve permissions in ZipFile.extract #32289

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 40 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
4fea41a
Add option to preserve permissions in ZipFile.extract
dignissimus Apr 3, 2022
f2f44ea
Update ACKS
dignissimus Apr 3, 2022
a09e5d3
Document option contents
dignissimus Apr 3, 2022
e2fa678
Document preserve_permissions parameter
dignissimus Apr 4, 2022
8a056b4
Continue work on tests
dignissimus Apr 5, 2022
a56d934
Fix tests
dignissimus Apr 12, 2022
06aff01
Skip tests on windows
dignissimus Apr 13, 2022
9a4b4f6
Add news entry
dignissimus Apr 13, 2022
5776f4c
Add tests for special file permissions
dignissimus Apr 13, 2022
8a3a778
Re-introduce tests for mode 0
dignissimus Apr 13, 2022
8a6a2c4
Use enumeration type in place of constants
dignissimus Apr 13, 2022
6470201
Document enumeration type
dignissimus Apr 13, 2022
5c8d82b
Check whether files were created on unix systems before extrating per…
dignissimus Apr 22, 2022
89da6f2
Remove trailing whitespace
dignissimus Apr 22, 2022
30e5528
Check file permissions against the umask
dignissimus Apr 24, 2022
5a1edac
Document that permissions are not preserved with archives created on …
dignissimus Apr 27, 2022
8a82294
Merge branch 'main' into bpo-15795
dignissimus Apr 27, 2022
41a350d
minor changes
merwok Feb 7, 2025
1b09aec
Merge branch 'main' into bpo-15795
dignissimus Feb 15, 2025
c456bb3
Qualify PreserveMode as zipfile.PreserveMode in documentation
dignissimus Feb 15, 2025
5616e0a
Revert PreserveMode -> zipfile.PreserveMode in documentation
dignissimus Feb 15, 2025
0065d2e
Replace dash with verb phrase in documentation for zipfile
dignissimus Feb 15, 2025
4064c32
Add versionchanged note in documentation for preserve_permissions in …
dignissimus Feb 15, 2025
f2c816a
Qualify ZipFile method references in documentation
dignissimus Feb 15, 2025
7d72b3b
Qualify zipfile.ZipFile.extract in news entry
dignissimus Feb 15, 2025
cb2478b
Disable tests that require root on wasi
dignissimus Feb 15, 2025
a082204
Disable tests that require file permissions on wasi
dignissimus Feb 15, 2025
fbba438
Move zipfile.PreserveMode description into docstring
dignissimus Feb 15, 2025
0d6b864
Remove superfluous newline
dignissimus Feb 15, 2025
b466493
Align method arguments in zipfile documentation
dignissimus Feb 15, 2025
9e94581
Separate clause with comma
dignissimus Feb 23, 2025
500ae2e
Documentation brevity
dignissimus Feb 23, 2025
a81a857
Merge branch 'main' into bpo-15795
dignissimus Feb 23, 2025
2cad611
Merge branch 'main' into bpo-15795
dignissimus Mar 4, 2025
5ef404a
Deduplicate Zipile path and pwd parameters
dignissimus Mar 4, 2025
c74c006
Separate permission-setting functionality from zipfile extraction code
dignissimus Mar 4, 2025
865771c
Merge branch 'main' into bpo-15795
dignissimus Mar 4, 2025
a2d7e77
Separate permission-setting functionality from zipfile extraction code
dignissimus Mar 5, 2025
44f5c89
Merge branch 'main' into bpo-15795
dignissimus Apr 13, 2025
9f5ebc3
Merge branch 'main' into bpo-15795
dignissimus Apr 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions Doc/library/zipfile.rst
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,29 @@ The module defines the following items:
methods, and may either refuse to process the ZIP file altogether,
or fail to extract individual files.

.. data:: PreserveMode.NONE

Constant for use in :meth:`ZipFile.extractall` and :meth:`ZipFile.extract` methods. Do not
preserve permissions of zipped files.

.. versionadded:: next

.. data:: PreserveMode.SAFE

Constant for use in :meth:`ZipFile.extractall` and :meth:`ZipFile.extract` methods.
Preserve safe subset of permissions of the zipped files only: permissions
for reading, writing, execution for user, group and others.

.. versionadded:: next

.. data:: PreserveMode.ALL

Constant for use in :meth:`ZipFile.extractall` and :meth:`ZipFile.extract` methods.
Preserve all the permissions of the zipped files, including unsafe ones:
UID bit (:data:`stat.S_ISUID`), group UID bit (:data:`stat.S_ISGID`),
sticky bit (:data:`stat.S_ISVTX`).

.. versionadded:: next

.. seealso::

Expand Down Expand Up @@ -347,13 +370,15 @@ 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, \
preserve_permissions=PreserveMode.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.
extracted as accurately as possible. *member* can be a filename or a
:class:`ZipInfo` object.

*path*, *pwd*, and *preserve_permissions* have the same meaning as for :meth:`extract`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a mis-copy perhaps?


Returns the normalized path created (a directory or new file).

Expand All @@ -374,13 +399,17 @@ ZipFile Objects
.. versionchanged:: 3.6.2
The *path* parameter accepts a :term:`path-like object`.

.. versionchanged:: next
The *preserve_permissions* parameter was added.

.. method:: ZipFile.extractall(path=None, members=None, pwd=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.
.. method:: ZipFile.extractall(path=None, members=None, pwd=None, \
preserve_permissions=PreserveMode.NONE)

Extract all members from the archive to the current working directory.
*members* is optional and must be a subset of the list returned by :meth:`namelist`.

*path*, *pwd*, and *preserve_permissions* have the same meaning as for :meth:`extract`.

.. warning::

Expand All @@ -397,6 +426,9 @@ ZipFile Objects
.. versionchanged:: 3.6.2
The *path* parameter accepts a :term:`path-like object`.

.. versionchanged:: next
The *preserve_permissions* parameter was added.


.. method:: ZipFile.printdir()

Expand Down
86 changes: 85 additions & 1 deletion Lib/test/test_zipfile/test_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import _pyio
import array
import contextlib
import functools
import importlib.util
import io
import itertools
Expand Down Expand Up @@ -49,6 +50,12 @@ def get_files(test):
yield f
test.assertFalse(f.closed)

def isroot():
if sys.platform == "win32" or sys.platform == "wasi":
return False

return os.getuid() == 0

class AbstractTestsWithSourceFile:
@classmethod
def setUpClass(cls):
Expand Down Expand Up @@ -1534,7 +1541,6 @@ def test_write_pathlike(self):


class ExtractTests(unittest.TestCase):

def make_test_file(self):
with zipfile.ZipFile(TESTFN2, "w", zipfile.ZIP_STORED) as zipfp:
for fpath, fdata in SMALL_TEST_DATA:
Expand Down Expand Up @@ -2582,6 +2588,84 @@ def tearDown(self):
unlink(TESTFN)
unlink(TESTFN2)

@unittest.skipIf(sys.platform == "win32" or sys.platform == "wasi", "Requires file permissions")
class TestsPermissionExtraction(unittest.TestCase):
def setUp(self):
os.mkdir(TESTFNDIR)
self.files = []

# Arbitrarily set the umask to 0 in order to retrieve the current umask
# Then restore the original value for the umask
self.umask = os.umask(0)
os.umask(self.umask)

with zipfile.ZipFile(TESTFN2, 'w', zipfile.ZIP_STORED) as zf:
for base_mode in range(0o1000):
for special_mask in range(0o10):
mode = stat.S_IFREG | base_mode | special_mask << 9
filename = os.path.join(TESTFNDIR, str(mode))
zinfo = zipfile.ZipInfo(filename)
zinfo.external_attr = (mode << 16)
zf.writestr(zinfo, filename)
self.files.append((filename, mode))

def tearDown(self):
os.unlink(TESTFN2)
rmtree(TESTFNDIR)

def test_extractall_preserve_none(self):
with zipfile.ZipFile(TESTFN2, 'r') as zf:
zf.extractall()
for filename, mode in self.files:
expected_mode = 0o666 & ~self.umask
self.assertTrue(os.path.exists(filename))
self.assertEqual(os.stat(filename).st_mode,
stat.S_IFREG | expected_mode)

def test_extract_preserve_none(self):
with zipfile.ZipFile(TESTFN2, 'r') as zf:
for filename, mode in self.files:
zf.extract(filename)
expected_mode = 0o666 & ~self.umask
self.assertTrue(os.path.exists(filename))
self.assertEqual(os.stat(filename).st_mode,
stat.S_IFREG | expected_mode)

def test_extractall_preserve_safe(self):
with zipfile.ZipFile(TESTFN2, 'r') as zf:
zf.extractall(preserve_permissions=zipfile.PreserveMode.SAFE)
for filename, mode in self.files:
self.assertTrue(os.path.exists(filename))
self.assertEqual(os.stat(filename).st_mode,
stat.S_IFREG | (mode & 0o777))

def test_extract_preserve_safe(self):
with zipfile.ZipFile(TESTFN2, 'r') as zf:
for filename, mode in self.files:
zf.extract(filename,
preserve_permissions=zipfile.PreserveMode.SAFE)
self.assertTrue(os.path.exists(filename))
self.assertEqual(os.stat(filename).st_mode,
stat.S_IFREG | (mode & 0o777))

@unittest.skipUnless(isroot(), "requires root")
def test_extractall_preserve_all(self):
with zipfile.ZipFile(TESTFN2, 'r') as zf:
zf.extractall(preserve_permissions=zipfile.PreserveMode.ALL)
for filename, mode in self.files:
self.assertTrue(os.path.exists(filename))
self.assertEqual(os.stat(filename).st_mode,
stat.S_IFREG | mode)

@unittest.skipUnless(isroot(), "requires root")
def test_extract_preserve_all(self):
with zipfile.ZipFile(TESTFN2, 'r') as zf:
for filename, mode in self.files:
zf.extract(filename,
preserve_permissions=zipfile.PreserveMode.ALL)
self.assertTrue(os.path.exists(filename))
self.assertEqual(os.stat(filename).st_mode,
stat.S_IFREG | mode)

class AbstractBadCrcTests:
def test_testzip_with_bad_crc(self):
Expand Down
49 changes: 42 additions & 7 deletions Lib/zipfile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
XXX references to utf-8 need further investigation.
"""
import binascii
import contextlib
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused?

import enum
import importlib.util
import io
import os
import pathlib
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is used?

import shutil
import stat
import struct
Expand All @@ -33,6 +36,7 @@

__all__ = ["BadZipFile", "BadZipfile", "error",
"ZIP_STORED", "ZIP_DEFLATED", "ZIP_BZIP2", "ZIP_LZMA",
"PreserveMode",
"is_zipfile", "ZipInfo", "ZipFile", "PyZipFile", "LargeZipFile",
"Path"]

Expand Down Expand Up @@ -373,6 +377,12 @@ def _sanitize_filename(filename):
return filename


class PreserveMode(enum.Enum):
"""Options for preserving file permissions upon extraction."""
NONE = enum.auto()
SAFE = enum.auto()
ALL = enum.auto()
Comment on lines +380 to +384
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There needs to be a mode for x bits only - see #59999 (comment) . (Also this is the behaviour I want and how I found this issue.)

It is not very natural to use an enum here - much more general is to define preserve_mode taking an int so that I can pass 0o333 or something to preserve wx bits and default the r bit to 1 (from a default of 0o666).

The constants here could directly be

PRESERVE_NONE = 0
PRESERVE_SAFE = 0o0777
PRESERVE_ALL = 0o7777


class ZipInfo:
"""Class with attributes describing each file in the ZIP archive."""

Expand Down Expand Up @@ -1792,26 +1802,30 @@ 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,
preserve_permissions=PreserveMode.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'. `preserve_permissions'
controls whether permissions of zipped files are preserved.
"""
if path is None:
path = os.getcwd()
else:
path = os.fspath(path)

return self._extract_member(member, path, pwd)
return self._extract_member(member, path, pwd, preserve_permissions)

def extractall(self, path=None, members=None, pwd=None):
def extractall(self, path=None, members=None, pwd=None,
preserve_permissions=PreserveMode.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'. `preserve_permissions' controls whether permissions
of zipped files are preserved.
"""
if members is None:
members = self.namelist()
Expand All @@ -1822,7 +1836,7 @@ def extractall(self, path=None, members=None, pwd=None):
path = os.fspath(path)

for zipinfo in members:
self._extract_member(zipinfo, path, pwd)
self._extract_member(zipinfo, path, pwd, preserve_permissions)

@classmethod
def _sanitize_windows_name(cls, arcname, pathsep):
Expand All @@ -1839,7 +1853,27 @@ def _sanitize_windows_name(cls, arcname, pathsep):
arcname = pathsep.join(x for x in arcname if x)
return arcname

def _extract_member(self, member, targetpath, pwd):
def _apply_permissions(self, member, path, mode):
"""
Apply ZipFile permissions to a file on the filesystem with
specified PreserveMode
"""
if mode == PreserveMode.NONE:
return

# Ignore permissions if the archive was created on Windows
if member.create_system == 0:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the check should probably be more conservative than this, there is also 10 which is Windows NTFS, and 20-255 are unused and could be invalid for this use case. An allow-list would be safer.

return

mask = {
PreserveMode.SAFE: 0o777,
PreserveMode.ALL: 0xFFFF,
}
Comment on lines +1868 to +1871
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could define

class PreserveMode(enum.IntEnum):
    NONE = 0
    SAFE = 0o777
    ALL = 0o7777

then here it's just

(member.external_attr >> 16) & mode

(However, see my other comment about not restricting this to specific values - users may have arbitrary requirements that an int can capture better.)

new_mode = (member.external_attr >> 16) & mask[mode]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should honour the current umask. It's clumsy to get the current umask so the easiest way to do this is not to chmod the file after creation but to pass the requested mode bits into os.open() with os.O_CREAT when opening the target file for writing. It's also one fewer syscall so might be slightly faster.

os.chmod(path, new_mode)

def _extract_member(self, member, targetpath, pwd,
preserve_permissions=PreserveMode.NONE):
"""Extract the ZipInfo object 'member' to a physical
file on the path targetpath.
"""
Expand Down Expand Up @@ -1886,6 +1920,7 @@ def _extract_member(self, member, targetpath, pwd):
open(targetpath, "wb") as target:
shutil.copyfileobj(source, target)

self._apply_permissions(member, targetpath, preserve_permissions)
return targetpath

def _writecheck(self, zinfo):
Expand Down
1 change: 1 addition & 0 deletions Misc/ACKS
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ David Bonner
Angelin Booz
Médéric Boquien
Matias Bordese
Alexey Boriskin
Jonas Borgström
Jurjen Bos
Peter Bosch
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add options to preserve file permissions in :meth:`zipfile.ZipFile.extract` and :meth:`zipfile.ZipFile.extractall`
Loading