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.