https://github.com/python/cpython/commit/ed9cb4b5e90065d04cb6fb6c774f9f53628d65bf
commit: ed9cb4b5e90065d04cb6fb6c774f9f53628d65bf
branch: main
author: Gregory P. Smith <[email protected]>
committer: gpshead <[email protected]>
date: 2026-11-01T19:04:06Z
summary:
gh-161088: Make the gzip CLI replace its output file like gzip(1) (GH-161089)
Remove an existing file at the output name and create the output with
O_EXCL and the input file's permission bits, so a symbolic link there is
no longer followed.
ANT-2026-00DXM84J ANT-2026-746G5PBP
files:
A Misc/NEWS.d/next/Library/2026-10-22-05-23-40.gh-issue-161088._-HEWz.rst
M Doc/library/gzip.rst
M Lib/gzip.py
M Lib/test/test_gzip.py
diff --git a/Doc/library/gzip.rst b/Doc/library/gzip.rst
index f32a54f0e35a02..3a050873b79b9d 100644
--- a/Doc/library/gzip.rst
+++ b/Doc/library/gzip.rst
@@ -307,6 +307,13 @@ Once executed the :mod:`!gzip` module keeps the input
file(s).
Add a new command line interface with a usage.
By default, when you will execute the CLI, the default compression level is
6.
+.. versionchanged:: next
+
+ An existing output file is removed and created anew instead of being
+ opened for writing, so a symbolic link at that name is replaced rather
+ than followed. Like :program:`gzip`, the output file is created with the
+ permission bits of the input file.
+
Command-line options
^^^^^^^^^^^^^^^^^^^^
diff --git a/Lib/gzip.py b/Lib/gzip.py
index 7e6a5197234ccc..3ed2055d801a3c 100644
--- a/Lib/gzip.py
+++ b/Lib/gzip.py
@@ -725,6 +725,25 @@ def decompress(data):
break
+def _create_output(filename, infile):
+ # Like "gzip -f": remove an existing file at the output name instead of
+ # writing through it (it may be a link to another file), and create the
+ # new file with the permission bits of the input file.
+ def opener(path, flags):
+ return os.open(path, flags, mode)
+
+ try:
+ mode = os.fstat(infile.fileno()).st_mode & 0o777
+ try:
+ os.unlink(filename)
+ except FileNotFoundError:
+ pass
+ return builtins.open(filename, "xb", opener=opener)
+ except:
+ infile.close()
+ raise
+
+
def main():
from argparse import ArgumentParser
parser = ArgumentParser(description=
@@ -747,6 +766,7 @@ def main():
compresslevel = _COMPRESS_LEVEL_BEST
for arg in args.args:
+ outfile = None
if args.decompress:
if arg == "-":
f = GzipFile(filename="", mode="rb", fileobj=sys.stdin.buffer)
@@ -755,7 +775,7 @@ def main():
if arg[-3:] != ".gz":
sys.exit(f"filename doesn't end in .gz: {arg!r}")
f = open(arg, "rb")
- g = builtins.open(arg[:-3], "wb")
+ g = outfile = _create_output(arg[:-3], f)
else:
if arg == "-":
f = sys.stdin.buffer
@@ -763,7 +783,9 @@ def main():
compresslevel=compresslevel)
else:
f = builtins.open(arg, "rb")
- g = open(arg + ".gz", "wb", compresslevel=compresslevel)
+ outfile = _create_output(arg + ".gz", f)
+ g = GzipFile(fileobj=outfile, mode="wb",
+ compresslevel=compresslevel)
while True:
chunk = f.read(READ_BUFFER_SIZE)
if not chunk:
@@ -771,6 +793,8 @@ def main():
g.write(chunk)
if g is not sys.stdout.buffer:
g.close()
+ if outfile is not None:
+ outfile.close()
if f is not sys.stdin.buffer:
f.close()
diff --git a/Lib/test/test_gzip.py b/Lib/test/test_gzip.py
index 1d37ba2f6817f7..b0706fdcabfdc8 100644
--- a/Lib/test/test_gzip.py
+++ b/Lib/test/test_gzip.py
@@ -6,6 +6,7 @@
import gc
import io
import os
+import stat
import struct
import sys
import unittest
@@ -1196,6 +1197,26 @@ def test_decompress_infile_outfile(self):
self.assertEqual(out, b'')
self.assertEqual(err, b'')
+ @os_helper.skip_unless_symlink
+ @create_and_remove_directory(TEMPDIR)
+ def test_decompress_does_not_follow_outfile_symlink(self):
+ gzipname = os.path.join(TEMPDIR, 'testgzip.gz')
+ outname = os.path.join(TEMPDIR, 'testgzip')
+ target = os.path.join(TEMPDIR, 'target')
+ with gzip.open(gzipname, mode='wb') as fp:
+ fp.write(self.data)
+ with open(target, 'wb') as fp:
+ fp.write(b'precious')
+ os.symlink(target, outname)
+
+ assert_python_ok('-m', 'gzip', '-d', gzipname)
+
+ self.assertFalse(os.path.islink(outname))
+ with open(outname, 'rb') as fp:
+ self.assertEqual(fp.read(), self.data)
+ with open(target, 'rb') as fp:
+ self.assertEqual(fp.read(), b'precious')
+
def test_decompress_infile_outfile_error(self):
rc, out, err = assert_python_failure('-m', 'gzip', '-d',
'thisisatest.out')
self.assertEqual(b"filename doesn't end in .gz: 'thisisatest.out'",
err.strip())
@@ -1249,6 +1270,62 @@ def test_compress_infile_outfile(self):
os.remove(gzipname)
self.assertFalse(os.path.exists(gzipname))
+ @os_helper.skip_unless_symlink
+ @create_and_remove_directory(TEMPDIR)
+ def test_compress_does_not_follow_outfile_symlink(self):
+ local_testgzip = os.path.join(TEMPDIR, 'testgzip')
+ gzipname = local_testgzip + '.gz'
+ target = os.path.join(TEMPDIR, 'target')
+ with open(local_testgzip, 'wb') as fp:
+ fp.write(self.data)
+ with open(target, 'wb') as fp:
+ fp.write(b'precious')
+ os.symlink(target, gzipname)
+
+ assert_python_ok('-m', 'gzip', local_testgzip)
+
+ self.assertFalse(os.path.islink(gzipname))
+ with gzip.open(gzipname, 'rb') as fp:
+ self.assertEqual(fp.read(), self.data)
+ with open(target, 'rb') as fp:
+ self.assertEqual(fp.read(), b'precious')
+
+ @unittest.skipUnless(os.name == 'posix', 'requires POSIX file permissions')
+ @os_helper.skip_unless_working_chmod
+ @create_and_remove_directory(TEMPDIR)
+ def test_infile_outfile_permissions(self):
+ local_testgzip = os.path.join(TEMPDIR, 'testgzip')
+ gzipname = local_testgzip + '.gz'
+
+ def create_old_output(filename):
+ # An existing output file is replaced, it does not keep its mode.
+ os_helper.unlink(filename)
+ with open(filename, 'wb') as fp:
+ fp.write(b'old content' * 100)
+ os.chmod(filename, 0o644)
+
+ for mode in 0o400, 0o600, 0o640, 0o666:
+ expected = mode & ~0o022
+ with self.subTest(mode=oct(mode)), os_helper.temp_umask(0o022):
+ os_helper.unlink(local_testgzip)
+ with open(local_testgzip, 'wb') as fp:
+ fp.write(self.data)
+ os.chmod(local_testgzip, mode)
+ create_old_output(gzipname)
+
+ assert_python_ok('-m', 'gzip', local_testgzip)
+ self.assertEqual(stat.S_IMODE(os.stat(gzipname).st_mode),
+ expected)
+ with gzip.open(gzipname) as fp:
+ self.assertEqual(fp.read(), self.data)
+
+ create_old_output(local_testgzip)
+ assert_python_ok('-m', 'gzip', '-d', gzipname)
+ self.assertEqual(stat.S_IMODE(os.stat(local_testgzip).st_mode),
+ expected)
+ with open(local_testgzip, 'rb') as fp:
+ self.assertEqual(fp.read(), self.data)
+
def test_compress_fast_best_are_exclusive(self):
rc, out, err = assert_python_failure('-m', 'gzip', '--fast', '--best')
self.assertIn(b"error: argument --best: not allowed with argument
--fast", err)
diff --git
a/Misc/NEWS.d/next/Library/2026-10-22-05-23-40.gh-issue-161088._-HEWz.rst
b/Misc/NEWS.d/next/Library/2026-10-22-05-23-40.gh-issue-161088._-HEWz.rst
new file mode 100644
index 00000000000000..cd8820baffa08f
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-10-22-05-23-40.gh-issue-161088._-HEWz.rst
@@ -0,0 +1,3 @@
+Fix the :mod:`gzip` command-line interface writing through a symbolic link
+at the output file name. Like :program:`gzip`, it now replaces an existing
+output file with a new one that has the permission bits of the input file.
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]