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]

Reply via email to