Skip to content

Commit 9dcef46

Browse files
committed
gh-88661: Derive the gzip FNAME field like gunzip does
The FNAME field of the gzip header is meant to hold the name the file is expected to have after decompression, but gzip and tarfile only stripped a literal ".gz" suffix. An archive created as "spam.tgz" therefore recorded "spam.tgz", so decompressors that honor FNAME wrote out a tar file still named ".tgz". Match gunzip instead: compare the suffix ignoring case, and replace ".tgz" and ".taz" with ".tar" rather than leaving them in place. This covers both code paths, GzipFile._write_gzip_header (mode "w:gz") and tarfile._Stream._init_write_gz (mode "w|gz"). Only the suffix is matched ignoring case; the rest of the name keeps the case it was given. gunzip behaves the same way: get_suffix() lowercases only the trailing bytes it compares, and make_ofname() calls strlwr() on the suffix alone, so "SPAM.TGZ" becomes "SPAM.tar". Both branches are tested with an upper-case stem. Deliberately left out, being separate user-visible changes rather than part of deriving the FNAME field: the remaining suffixes gunzip knows (".z", "-gz", "-z" and "_z"), and the "python -m gzip -d" CLI, which still accepts only a literal ".gz" argument. ".tz" is not added because it is not a gunzip suffix at all, unlike ".taz".
1 parent 0b72907 commit 9dcef46

6 files changed

Lines changed: 96 additions & 4 deletions

File tree

‎Doc/whatsnew/3.16.rst‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,16 @@ that may require changes to your code.
10451045
raises :exc:`io.UnsupportedOperation` unless buffering is disabled.
10461046
(Contributed by An Long in :gh:`86768`.)
10471047

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

10491059
Build changes
10501060
=============

‎Lib/gzip.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,12 @@ def _write_gzip_header(self, compresslevel):
288288
fname = os.path.basename(self.name)
289289
if not isinstance(fname, bytes):
290290
fname = fname.encode('latin-1')
291-
if fname.endswith(b'.gz'):
291+
# Like gunzip, match the suffix ignoring case, and turn ".tgz"
292+
# and ".taz" into ".tar" instead of just stripping them.
293+
if fname[-3:].lower() == b'.gz':
292294
fname = fname[:-3]
295+
elif fname[-4:].lower() in (b'.tgz', b'.taz'):
296+
fname = fname[:-4] + b'.tar'
293297
except UnicodeEncodeError:
294298
fname = b''
295299
flags = 0

‎Lib/tarfile.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,8 +456,12 @@ def _init_write_gz(self, compresslevel, mtime):
456456
mtime = int(time.time())
457457
timestamp = struct.pack("<L", mtime)
458458
self.__write(b"\037\213\010\010" + timestamp + b"\002\377")
459-
if self.name.endswith(".gz"):
459+
# Like gunzip, match the suffix ignoring case, and turn ".tgz"
460+
# and ".taz" into ".tar" instead of just stripping them.
461+
if self.name[-3:].lower() == ".gz":
460462
self.name = self.name[:-3]
463+
elif self.name[-4:].lower() in (".tgz", ".taz"):
464+
self.name = self.name[:-4] + ".tar"
461465
# Honor "directory components removed" from RFC1952
462466
self.name = os.path.basename(self.name)
463467
# RFC1952 says we must use ISO-8859-1 for the FNAME field.

‎Lib/test/test_gzip.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,40 @@ def test_metadata_ascii_name(self):
437437
self.filename = os_helper.TESTFN_ASCII
438438
self.test_metadata()
439439

440+
def test_metadata_name_suffix(self):
441+
# gh-88661: the FNAME field holds the name that the file is expected
442+
# to have after decompression. Like gunzip, the suffix is matched
443+
# ignoring case, and ".tgz" and ".taz" are turned into ".tar".
444+
base = os_helper.TESTFN_ASCII
445+
# Only the suffix is matched ignoring case; the rest of the name
446+
# keeps the case it was given, the way make_ofname() does in gunzip.
447+
upper = base.upper()
448+
for filename, expected in ((base, base),
449+
(base + '.gz', base),
450+
(base + '.GZ', base),
451+
(base + '.gZ', base),
452+
(base + '.tgz', base + '.tar'),
453+
(base + '.TGZ', base + '.tar'),
454+
(base + '.tGz', base + '.tar'),
455+
(base + '.taz', base + '.tar'),
456+
(base + '.TAZ', base + '.tar'),
457+
(base + '.tar', base + '.tar'),
458+
(base + '.tgz.gz', base + '.tgz'),
459+
(upper + '.GZ', upper),
460+
(upper + '.TGZ', upper + '.tar'),
461+
(upper + '.TAZ', upper + '.tar')):
462+
with self.subTest(filename=filename):
463+
try:
464+
with gzip.GzipFile(filename, 'w') as f:
465+
f.write(data1)
466+
with open(filename, 'rb') as f:
467+
header = f.read(1024)
468+
self.assertEqual(header[3], 8) # only the FNAME flag
469+
fname = header[10:header.index(b'\0', 10)]
470+
self.assertEqual(fname.decode('latin-1'), expected)
471+
finally:
472+
os_helper.unlink(filename)
473+
440474
def test_compresslevel_metadata(self):
441475
# see RFC 1952: http://www.faqs.org/rfcs/rfc1952.html
442476
# specifically, discussion of XFL in section 2.3.1

‎Lib/test/test_tarfile.py‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1967,7 +1967,41 @@ def test_missing_fileobj(self):
19671967
tar.addfile(tarinfo)
19681968

19691969

1970-
class GzipWriteTest(GzipTest, WriteTest):
1970+
class GzipFnameTestBase:
1971+
# gh-88661: the FNAME field of the gzip header holds the name that the
1972+
# archive is expected to have after decompression. Like gunzip, the
1973+
# suffix is matched ignoring case, and ".tgz" and ".taz" become ".tar".
1974+
1975+
def gzip_header_fname(self, path):
1976+
with open(path, "rb") as fobj:
1977+
header = fobj.read(1024)
1978+
self.assertEqual(header[:2], b"\037\213") # gzip magic number
1979+
self.assertEqual(header[3], 8) # only the FNAME flag
1980+
return header[10:header.index(b"\0", 10)].decode("latin-1")
1981+
1982+
def test_fname(self):
1983+
# Only the suffix is matched ignoring case; the rest of the name
1984+
# keeps the case it was given, the way make_ofname() does in gunzip.
1985+
for name, expected in (("tmp.tar.gz", "tmp.tar"),
1986+
("tmp.TAR.GZ", "tmp.TAR"),
1987+
("tmp.tgz", "tmp.tar"),
1988+
("tmp.TGZ", "tmp.tar"),
1989+
("tmp.tGz", "tmp.tar"),
1990+
("tmp.taz", "tmp.tar"),
1991+
("tmp.TAZ", "tmp.tar"),
1992+
("TMP.TGZ", "TMP.tar"),
1993+
("TMP.TAZ", "TMP.tar"),
1994+
("tmp.tar", "tmp.tar")):
1995+
with self.subTest(name=name):
1996+
path = os.path.join(TEMPDIR, name)
1997+
try:
1998+
tarfile.open(path, self.mode).close()
1999+
self.assertEqual(self.gzip_header_fname(path), expected)
2000+
finally:
2001+
os_helper.unlink(path)
2002+
2003+
2004+
class GzipWriteTest(GzipTest, GzipFnameTestBase, WriteTest):
19712005
pass
19722006

19732007

@@ -2034,7 +2068,7 @@ def test_pathlike_name(self):
20342068
os_helper.unlink(tmpname)
20352069

20362070

2037-
class GzipStreamWriteTest(GzipTest, StreamWriteTest):
2071+
class GzipStreamWriteTest(GzipTest, GzipFnameTestBase, StreamWriteTest):
20382072
def test_source_directory_not_leaked(self):
20392073
"""
20402074
Ensure the source directory is not included in the tar header
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
When writing a gzip header, :mod:`gzip` and :mod:`tarfile` now derive the
2+
``FNAME`` field from the file name like :program:`gunzip` does for the
3+
``.gz``, ``.tgz`` and ``.taz`` suffixes: the suffix is matched ignoring
4+
case, and ``.tgz`` and ``.taz`` are replaced with ``.tar`` instead of being
5+
left in place. Previously an archive created as :file:`spam.tgz` recorded
6+
``spam.tgz`` as the name to decompress to.

0 commit comments

Comments
 (0)