Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
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
22 changes: 21 additions & 1 deletion Lib/tarfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
# Imports
#---------
from builtins import open as bltn_open
import errno
import sys
import os
import io
Expand Down Expand Up @@ -2567,7 +2568,26 @@ def chmod(self, tarinfo, targetpath):
if tarinfo.mode is None:
return
try:
os.chmod(targetpath, tarinfo.mode)
try:
os.chmod(targetpath, tarinfo.mode)
except OSError as exc1:
# gh-108948: On FreeBSD, chmod() fails when trying to set the
# sticky bit on a file as non-root. On other platforms, the bit
# is silently ignored if it cannot be set. We make FreeBSD
# behave like other platforms by catching the error and trying
# again without the sticky bit.
if (hasattr(errno, "EFTYPE") and exc1.errno == errno.EFTYPE
and (tarinfo.mode & stat.S_ISVTX)):
try:
# Retry without the sticky bit
os.chmod(targetpath, tarinfo.mode & ~stat.S_ISVTX)
except OSError as exc2:
# The error from the second attempt is the direct cause
# of the ExtractError, but we keep the original error
# around for good information.
raise exc2 from exc1
else:
raise
except OSError as e:
raise ExtractError("could not change mode") from e
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
except OSError as e:
raise ExtractError("could not change mode") from e
except OSError as exc3:
raise ExtractError("could not change mode") from exc3

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done


Expand Down
43 changes: 42 additions & 1 deletion Lib/test/test_tarfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import re
import warnings
import stat
import errno

import unittest
import unittest.mock
Expand Down Expand Up @@ -690,6 +691,36 @@ def format_mtime(mtime):
tar.close()
os_helper.rmtree(DIR)

@unittest.skipUnless(hasattr(errno, "EFTYPE"), "errno.EFTYPE required")
Copy link
Member

Choose a reason for hiding this comment

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

Would it make sense to mock this constant to test the code on other platforms?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I decided to go with it. Maybe it's overkill for such a niche case, but more tests rarely hurt. All new tests never skip.

def test_extract_chmod_eftype(self):
# Extracting a file as non-root should skip the sticky bit (gh-108948)
# even on platforms where chmod fails with EFTYPE (i.e. FreeBSD). But
# we need to take care that any other error is preserved.
mode = "-rwxrwxrwt"
with ArchiveMaker() as arc:
arc.add("sticky1", mode=mode)
arc.add("sticky2", mode=mode)
tar = arc.open(errorlevel=2)
DIR = os.path.join(TEMPDIR, "chmod")
os.mkdir(DIR)
self.addCleanup(os_helper.rmtree, DIR)
with tar:
# this should not raise:
tar.extract("sticky1", DIR, filter="fully_trusted")
Copy link
Member

@vstinner vstinner Sep 15, 2023

Choose a reason for hiding this comment

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

I think that you should split this test in two tests:

  • a method which doesn't mock anything and jsut check that tarfile works as expected: this line. -- functional test
  • another method which mocks chmod() to test the complicated EFTYPE code path.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. Again, maybe overkill, but it's three tests now.

got_mode = stat.filemode(os.stat(os.path.join(DIR, "sticky1")).st_mode)
expected_mode = "-rwxrwxrwx" if os.geteuid() != 0 else "-rwxrwxrwt"
self.assertEqual(got_mode, expected_mode)

# but we can create a situation where it does raise:
with unittest.mock.patch("os.chmod") as mock:
eftype_error = OSError(errno.EFTYPE, "EFTYPE")
other_error = OSError(errno.EPERM, "different error")
mock.side_effect = [eftype_error, other_error]
with self.assertRaises(tarfile.ExtractError) as excinfo:
tar.extract("sticky2", DIR, filter="fully_trusted")
Copy link
Member

Choose a reason for hiding this comment

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

Since tarfile implementation now uses a complicated triple-nested try block, would it be possible to test also the othe code path: EFTYPE error but then the second chmod() succeed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this is done now.

self.assertEqual(excinfo.exception.__cause__, other_error)
self.assertEqual(excinfo.exception.__cause__.__cause__, eftype_error)

@os_helper.skip_unless_working_chmod
def test_extract_directory(self):
dirtype = "ustar/dirtype"
Expand Down Expand Up @@ -3818,7 +3849,17 @@ def test_modes(self):
pass
new_mode = (os.stat(tmp_filename).st_mode
| stat.S_ISVTX | stat.S_ISGID | stat.S_ISUID)
os.chmod(tmp_filename, new_mode)
try:
os.chmod(tmp_filename, new_mode)
except OSError as err:
# gh-108948: While chmod on most platforms silently ignores the
# sticky bit if it cannot be set (i.e. setting it on a file as
# non-root), chmod on FreeBSD raises EFTYPE to indicate the case.
if hasattr(errno, "EFTYPE") and err.errno == errno.EFTYPE:
# Retry without the sticky bit
os.chmod(tmp_filename, new_mode & ~stat.S_ISVTX)
else:
raise
got_mode = os.stat(tmp_filename).st_mode
_t_file = 't' if (got_mode & stat.S_ISVTX) else 'x'
_suid_file = 's' if (got_mode & stat.S_ISUID) else 'x'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On FreeBSD, :mod:`tarfile` will not attempt to set the sticky bit on extracted files when it's not possible, matching the behavior of other platforms.
Copy link
Member

Choose a reason for hiding this comment

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

Hum. I suggest to explain it differently.

:mod:`tarfile`: On FreeBSD, if the sticky bit cannot be set,
if the user is not root for example, clear the sticky bit,
matching the behavior of other platforms.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe it would be worth it to add a note about the sticky bit in https://docs.python.org/dev/library/tarfile.html#tarfile.TarFile.extract documentation?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I went with Ethan's suggestion for the blurb.

I also added a note, but under the filters documentation, so if a user searches for "sticky" or "bits" or similar keywords they find relevant information right there. I can change it or add more, of course.