Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,16 @@ that may require changes to your code.
raises :exc:`io.UnsupportedOperation` unless buffering is disabled.
(Contributed by An Long in :gh:`86768`.)

* :mod:`gzip` and :mod:`tarfile` now derive the ``FNAME`` field of the gzip
header from the file name like :program:`gunzip` does for the ``.gz``,
``.tgz`` and ``.taz`` suffixes: the suffix is matched ignoring case, and
``.tgz`` and ``.taz`` are replaced with ``.tar`` instead of being left in
place. For example, an archive created as :file:`spam.tgz` now records
``spam.tar`` rather than ``spam.tgz``, and :file:`spam.GZ` records
``spam`` rather than ``spam.GZ``. Code comparing generated files byte for
byte may need to be updated.
(Contributed by Dmitry Voropaev in :gh:`88661`.)


Build changes
=============
Expand Down
6 changes: 5 additions & 1 deletion Lib/gzip.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,12 @@ def _write_gzip_header(self, compresslevel):
fname = os.path.basename(self.name)
if not isinstance(fname, bytes):
fname = fname.encode('latin-1')
if fname.endswith(b'.gz'):
# Like gunzip, match the suffix ignoring case, and turn ".tgz"
# and ".taz" into ".tar" instead of just stripping them.
if fname[-3:].lower() == b'.gz':
fname = fname[:-3]
elif fname[-4:].lower() in (b'.tgz', b'.taz'):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

.taz is for .tar.Z, but Python's gzip module does not support the .Z compression.

The reading side is now inconsistent with what we write:

$ python -m gzip -d spam.tgz
filename doesn't end in .gz: 'spam.tgz'

main() tests arg[-3:] != ".gz", so it rejects both spam.tgz and spam.GZ, which gunzip accepts. Could you fix it here?

fname = fname[:-4] + b'.tar'
except UnicodeEncodeError:
fname = b''
flags = 0
Expand Down
6 changes: 5 additions & 1 deletion Lib/tarfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,12 @@ def _init_write_gz(self, compresslevel, mtime):
mtime = int(time.time())
timestamp = struct.pack("<L", mtime)
self.__write(b"\037\213\010\010" + timestamp + b"\002\377")
if self.name.endswith(".gz"):
# Like gunzip, match the suffix ignoring case, and turn ".tgz"
# and ".taz" into ".tar" instead of just stripping them.
if self.name[-3:].lower() == ".gz":
self.name = self.name[:-3]
elif self.name[-4:].lower() in (".tgz", ".taz"):
self.name = self.name[:-4] + ".tar"
# Honor "directory components removed" from RFC1952
self.name = os.path.basename(self.name)
# RFC1952 says we must use ISO-8859-1 for the FNAME field.
Expand Down
34 changes: 34 additions & 0 deletions Lib/test/test_gzip.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,40 @@ def test_metadata_ascii_name(self):
self.filename = os_helper.TESTFN_ASCII
self.test_metadata()

def test_metadata_name_suffix(self):
# gh-88661: the FNAME field holds the name that the file is expected
# to have after decompression. Like gunzip, the suffix is matched
# ignoring case, and ".tgz" and ".taz" are turned into ".tar".
base = os_helper.TESTFN_ASCII
# Only the suffix is matched ignoring case; the rest of the name
# keeps the case it was given, the way make_ofname() does in gunzip.
upper = base.upper()
for filename, expected in ((base, base),
(base + '.gz', base),
(base + '.GZ', base),
(base + '.gZ', base),
(base + '.tgz', base + '.tar'),
(base + '.TGZ', base + '.tar'),
(base + '.tGz', base + '.tar'),
(base + '.taz', base + '.tar'),
(base + '.TAZ', base + '.tar'),
(base + '.tar', base + '.tar'),
(base + '.tgz.gz', base + '.tgz'),
(upper + '.GZ', upper),
(upper + '.TGZ', upper + '.tar'),
(upper + '.TAZ', upper + '.tar')):
with self.subTest(filename=filename):
try:
with gzip.GzipFile(filename, 'w') as f:
f.write(data1)
with open(filename, 'rb') as f:
header = f.read(1024)
self.assertEqual(header[3], 8) # only the FNAME flag
fname = header[10:header.index(b'\0', 10)]
self.assertEqual(fname.decode('latin-1'), expected)
finally:
os_helper.unlink(filename)

def test_compresslevel_metadata(self):
# see RFC 1952: http://www.faqs.org/rfcs/rfc1952.html
# specifically, discussion of XFL in section 2.3.1
Expand Down
38 changes: 36 additions & 2 deletions Lib/test/test_tarfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -1967,7 +1967,41 @@ def test_missing_fileobj(self):
tar.addfile(tarinfo)


class GzipWriteTest(GzipTest, WriteTest):
class GzipFnameTestBase:
# gh-88661: the FNAME field of the gzip header holds the name that the
# archive is expected to have after decompression. Like gunzip, the
# suffix is matched ignoring case, and ".tgz" and ".taz" become ".tar".

def gzip_header_fname(self, path):
with open(path, "rb") as fobj:
header = fobj.read(1024)
self.assertEqual(header[:2], b"\037\213") # gzip magic number
self.assertEqual(header[3], 8) # only the FNAME flag
return header[10:header.index(b"\0", 10)].decode("latin-1")

def test_fname(self):
# Only the suffix is matched ignoring case; the rest of the name
# keeps the case it was given, the way make_ofname() does in gunzip.
for name, expected in (("tmp.tar.gz", "tmp.tar"),
("tmp.TAR.GZ", "tmp.TAR"),
("tmp.tgz", "tmp.tar"),
("tmp.TGZ", "tmp.tar"),
("tmp.tGz", "tmp.tar"),
("tmp.taz", "tmp.tar"),
("tmp.TAZ", "tmp.tar"),
("TMP.TGZ", "TMP.tar"),
("TMP.TAZ", "TMP.tar"),
("tmp.tar", "tmp.tar")):
with self.subTest(name=name):
path = os.path.join(TEMPDIR, name)
try:
tarfile.open(path, self.mode).close()
self.assertEqual(self.gzip_header_fname(path), expected)
finally:
os_helper.unlink(path)


class GzipWriteTest(GzipTest, GzipFnameTestBase, WriteTest):
pass


Expand Down Expand Up @@ -2034,7 +2068,7 @@ def test_pathlike_name(self):
os_helper.unlink(tmpname)


class GzipStreamWriteTest(GzipTest, StreamWriteTest):
class GzipStreamWriteTest(GzipTest, GzipFnameTestBase, StreamWriteTest):
def test_source_directory_not_leaked(self):
"""
Ensure the source directory is not included in the tar header
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
When writing a gzip header, :mod:`gzip` and :mod:`tarfile` now derive the
``FNAME`` field from the file name like :program:`gunzip` does for the
``.gz``, ``.tgz`` and ``.taz`` suffixes: the suffix is matched ignoring
case, and ``.tgz`` and ``.taz`` are replaced with ``.tar`` instead of being
left in place. Previously an archive created as :file:`spam.tgz` recorded
``spam.tgz`` as the name to decompress to.
Loading