Skip to content

Commit ed9cb4b

Browse files
authored
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
1 parent 2a1282b commit ed9cb4b

4 files changed

Lines changed: 113 additions & 2 deletions

File tree

‎Doc/library/gzip.rst‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,13 @@ Once executed the :mod:`!gzip` module keeps the input file(s).
307307
Add a new command line interface with a usage.
308308
By default, when you will execute the CLI, the default compression level is 6.
309309

310+
.. versionchanged:: next
311+
312+
An existing output file is removed and created anew instead of being
313+
opened for writing, so a symbolic link at that name is replaced rather
314+
than followed. Like :program:`gzip`, the output file is created with the
315+
permission bits of the input file.
316+
310317
Command-line options
311318
^^^^^^^^^^^^^^^^^^^^
312319

‎Lib/gzip.py‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,25 @@ def decompress(data):
725725
break
726726

727727

728+
def _create_output(filename, infile):
729+
# Like "gzip -f": remove an existing file at the output name instead of
730+
# writing through it (it may be a link to another file), and create the
731+
# new file with the permission bits of the input file.
732+
def opener(path, flags):
733+
return os.open(path, flags, mode)
734+
735+
try:
736+
mode = os.fstat(infile.fileno()).st_mode & 0o777
737+
try:
738+
os.unlink(filename)
739+
except FileNotFoundError:
740+
pass
741+
return builtins.open(filename, "xb", opener=opener)
742+
except:
743+
infile.close()
744+
raise
745+
746+
728747
def main():
729748
from argparse import ArgumentParser
730749
parser = ArgumentParser(description=
@@ -747,6 +766,7 @@ def main():
747766
compresslevel = _COMPRESS_LEVEL_BEST
748767

749768
for arg in args.args:
769+
outfile = None
750770
if args.decompress:
751771
if arg == "-":
752772
f = GzipFile(filename="", mode="rb", fileobj=sys.stdin.buffer)
@@ -755,22 +775,26 @@ def main():
755775
if arg[-3:] != ".gz":
756776
sys.exit(f"filename doesn't end in .gz: {arg!r}")
757777
f = open(arg, "rb")
758-
g = builtins.open(arg[:-3], "wb")
778+
g = outfile = _create_output(arg[:-3], f)
759779
else:
760780
if arg == "-":
761781
f = sys.stdin.buffer
762782
g = GzipFile(filename="", mode="wb", fileobj=sys.stdout.buffer,
763783
compresslevel=compresslevel)
764784
else:
765785
f = builtins.open(arg, "rb")
766-
g = open(arg + ".gz", "wb", compresslevel=compresslevel)
786+
outfile = _create_output(arg + ".gz", f)
787+
g = GzipFile(fileobj=outfile, mode="wb",
788+
compresslevel=compresslevel)
767789
while True:
768790
chunk = f.read(READ_BUFFER_SIZE)
769791
if not chunk:
770792
break
771793
g.write(chunk)
772794
if g is not sys.stdout.buffer:
773795
g.close()
796+
if outfile is not None:
797+
outfile.close()
774798
if f is not sys.stdin.buffer:
775799
f.close()
776800

‎Lib/test/test_gzip.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import gc
77
import io
88
import os
9+
import stat
910
import struct
1011
import sys
1112
import unittest
@@ -1196,6 +1197,26 @@ def test_decompress_infile_outfile(self):
11961197
self.assertEqual(out, b'')
11971198
self.assertEqual(err, b'')
11981199

1200+
@os_helper.skip_unless_symlink
1201+
@create_and_remove_directory(TEMPDIR)
1202+
def test_decompress_does_not_follow_outfile_symlink(self):
1203+
gzipname = os.path.join(TEMPDIR, 'testgzip.gz')
1204+
outname = os.path.join(TEMPDIR, 'testgzip')
1205+
target = os.path.join(TEMPDIR, 'target')
1206+
with gzip.open(gzipname, mode='wb') as fp:
1207+
fp.write(self.data)
1208+
with open(target, 'wb') as fp:
1209+
fp.write(b'precious')
1210+
os.symlink(target, outname)
1211+
1212+
assert_python_ok('-m', 'gzip', '-d', gzipname)
1213+
1214+
self.assertFalse(os.path.islink(outname))
1215+
with open(outname, 'rb') as fp:
1216+
self.assertEqual(fp.read(), self.data)
1217+
with open(target, 'rb') as fp:
1218+
self.assertEqual(fp.read(), b'precious')
1219+
11991220
def test_decompress_infile_outfile_error(self):
12001221
rc, out, err = assert_python_failure('-m', 'gzip', '-d', 'thisisatest.out')
12011222
self.assertEqual(b"filename doesn't end in .gz: 'thisisatest.out'", err.strip())
@@ -1249,6 +1270,62 @@ def test_compress_infile_outfile(self):
12491270
os.remove(gzipname)
12501271
self.assertFalse(os.path.exists(gzipname))
12511272

1273+
@os_helper.skip_unless_symlink
1274+
@create_and_remove_directory(TEMPDIR)
1275+
def test_compress_does_not_follow_outfile_symlink(self):
1276+
local_testgzip = os.path.join(TEMPDIR, 'testgzip')
1277+
gzipname = local_testgzip + '.gz'
1278+
target = os.path.join(TEMPDIR, 'target')
1279+
with open(local_testgzip, 'wb') as fp:
1280+
fp.write(self.data)
1281+
with open(target, 'wb') as fp:
1282+
fp.write(b'precious')
1283+
os.symlink(target, gzipname)
1284+
1285+
assert_python_ok('-m', 'gzip', local_testgzip)
1286+
1287+
self.assertFalse(os.path.islink(gzipname))
1288+
with gzip.open(gzipname, 'rb') as fp:
1289+
self.assertEqual(fp.read(), self.data)
1290+
with open(target, 'rb') as fp:
1291+
self.assertEqual(fp.read(), b'precious')
1292+
1293+
@unittest.skipUnless(os.name == 'posix', 'requires POSIX file permissions')
1294+
@os_helper.skip_unless_working_chmod
1295+
@create_and_remove_directory(TEMPDIR)
1296+
def test_infile_outfile_permissions(self):
1297+
local_testgzip = os.path.join(TEMPDIR, 'testgzip')
1298+
gzipname = local_testgzip + '.gz'
1299+
1300+
def create_old_output(filename):
1301+
# An existing output file is replaced, it does not keep its mode.
1302+
os_helper.unlink(filename)
1303+
with open(filename, 'wb') as fp:
1304+
fp.write(b'old content' * 100)
1305+
os.chmod(filename, 0o644)
1306+
1307+
for mode in 0o400, 0o600, 0o640, 0o666:
1308+
expected = mode & ~0o022
1309+
with self.subTest(mode=oct(mode)), os_helper.temp_umask(0o022):
1310+
os_helper.unlink(local_testgzip)
1311+
with open(local_testgzip, 'wb') as fp:
1312+
fp.write(self.data)
1313+
os.chmod(local_testgzip, mode)
1314+
create_old_output(gzipname)
1315+
1316+
assert_python_ok('-m', 'gzip', local_testgzip)
1317+
self.assertEqual(stat.S_IMODE(os.stat(gzipname).st_mode),
1318+
expected)
1319+
with gzip.open(gzipname) as fp:
1320+
self.assertEqual(fp.read(), self.data)
1321+
1322+
create_old_output(local_testgzip)
1323+
assert_python_ok('-m', 'gzip', '-d', gzipname)
1324+
self.assertEqual(stat.S_IMODE(os.stat(local_testgzip).st_mode),
1325+
expected)
1326+
with open(local_testgzip, 'rb') as fp:
1327+
self.assertEqual(fp.read(), self.data)
1328+
12521329
def test_compress_fast_best_are_exclusive(self):
12531330
rc, out, err = assert_python_failure('-m', 'gzip', '--fast', '--best')
12541331
self.assertIn(b"error: argument --best: not allowed with argument --fast", err)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix the :mod:`gzip` command-line interface writing through a symbolic link
2+
at the output file name. Like :program:`gzip`, it now replaces an existing
3+
output file with a new one that has the permission bits of the input file.

0 commit comments

Comments
 (0)