Skip to content

gh-88661: Derive the gzip FNAME field like gunzip does - #158125

Open
v0ropaev wants to merge 1 commit into
python:mainfrom
v0ropaev:gh-88661-tgz-fname
Open

v0ropaev wants to merge 1 commit into
python:mainfrom
v0ropaev:gh-88661-tgz-fname

Conversation

@v0ropaev

@v0ropaev v0ropaev commented Sep 24, 2026 •

Copy link
Copy Markdown

Closes #88661.

An archive created as spam.tgz records spam.tgz as the name to decompress to, so gunzip on it produces a file called spam.tgz. Measured on main, reading FLG and the NUL-terminated FNAME straight out of the RFC 1952 header:

main this branch
tarfile.open("test.tgz", "w:gz") b'test.tgz' b'test.tar'
tarfile.open("s.tgz", "w|gz") b's.tgz' b's.tar'
tarfile.open("TEST.TGZ", "w:gz") b'TEST.TGZ' b'TEST.tar'
tarfile.open("test.tAr.Gz", "w:gz") b'test.tAr.Gz' b'test.tAr'
gzip.open("data.tgz", "wb") b'data.tgz' b'data.tar'
gzip.open("DATA.GZ", "wb") b'DATA.GZ' b'DATA'

Two independent code paths carry the same defect: GzipFile._write_gzip_header (used by gzip.open and by tarfile's w:gz) and _Stream._init_write_gz (w|gz). Both are fixed identically.

Following @serhiy-storchaka's direction

The command-line tool gunzip converts .tgz to .tar. It also ignores case, so it removes .GZ, .Gz, and .gZ, and converts .TgZ, .tGz, etc to .tar. I think this is a strong argument for option 1.

So the suffix is matched case-insensitively, and .tgz becomes .tar rather than being stripped. Matching the case-insensitivity is a wider change than the issue title suggests — gzip.open("DATA.GZ", "wb") now records DATA instead of DATA.GZ — so I am calling that out rather than letting it ride along.

The stem keeps its case and the suffix becomes lowercase .tar, which is what GNU make_ofname() does:

/* Make a special case for .tgz and .taz: */
strlwr(suff);
if (strequ(suff, ".tgz") || strequ(suff, ".taz")) {
    strcpy(suff, ".tar");
} else {
    *suff = '\0'; /* strip the z suffix */
}

.taz is included, which the issue does not mention. It sits in the same if as .tgz in make_ofname(), so implementing only half of the criterion Serhiy cited seemed worse than implementing it. The stdlib already treats the two alike — mimetypes.py:462 has '.tgz': '.tar.gz', '.taz': '.tar.gz'. .tz is not included: GNU handles it through the generic known_suffixes[] stripping, not the .tar special case.

The logic is duplicated rather than shared. _Stream.__init__ imports only zlib; gzip is pulled in lazily and only by TarFile.gzopen, so a shared helper in gzip.py would add a gzip import to the w|gz path for four lines. The duplication predates this change; both copies now say the same thing.

The comparison slices the original and lowercases only the tail rather than using fname.lower().endswith(...), because str.lower() can change length ('İ') and the slice index would then be wrong.

Not in scope, but noticed

python -m gzip -d (Lib/gzip.py:736) does if arg[-3:] != ".gz": sys.exit(...), so the CLI that describes itself as "act like gunzip" is still case-sensitive and refuses .tgz outright. That is a different behaviour — it rejects the file rather than misnaming a header field — so I left it alone. Happy to do it as a follow-up if you want the module internally consistent.

Verification

Run on a build of main (3.16.0a0):

./python.exe -m test test_gzip test_tarfile   →  run=863, skipped=5, SUCCESS

Reverting only Lib/gzip.py and Lib/tarfile.py to main while keeping the tests: run=863 failures=26, from two test methods (test_fname, test_metadata_name_suffix) whose subtests cover each suffix and case combination.

A mutation lowercasing the stem as well as the suffix — fname[:-4].lower() + b'.tar' — fails 2 tests, so the case-preserving half is pinned rather than incidental.

main only; I am not proposing a backport, since the change alters bytes in generated files. Misc/NEWS.d/next/Library/ entry added, plus a note under Porting to Python 3.16 rather than under Improved Modules, since the section is explicitly for "changes that may require changes to your code" and byte-for-byte comparisons of generated archives will shift.

Thanks to @miikka for laying out the options in the thread, including the case that GzipFile cannot know whether a .tgz really is a tarball — which is why this follows the file name like gunzip does rather than inspecting content.

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".
@read-the-docs-community

Copy link
Copy Markdown

Documentation build overview

📚 cpython-previews | 🛠️ Build #34743544 | 📁 Comparing 9dcef46 against main (69f98a5)

  🔍 Preview build  

2 files changed
± whatsnew/3.16.html
± whatsnew/changelog.html

Comment thread Lib/gzip.py
# 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wrong FNAME in tarfile if tgz extension is used

2 participants