Skip to content

Commit e6190ac

Browse files
authored
gh-160406: Keep the backup and create output exclusively in fileinput (GH-160407)
In-place filtering deleted the backup file, the only copy of the data, when the output file could not be created. An existing backup file is now only replaced once there is a file to back up, so trying again does not delete it either. The output file is now created with O_EXCL and its mode is set through the file descriptor. ANT-2026-3TZS4H87
1 parent 1a80f6d commit e6190ac

4 files changed

Lines changed: 126 additions & 10 deletions

File tree

‎Doc/library/fileinput.rst‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,16 @@ and the backup file remains around; by default, the extension is ``'.bak'`` and
189189
it is deleted when the output file is closed. In-place filtering is disabled
190190
when standard input is read.
191191

192+
The output file is created exclusively: if something else creates a file or
193+
symbolic link with the name of the input file after it has been moved to the
194+
backup file, :exc:`FileExistsError` is raised. If the output file cannot be
195+
created, the backup file is kept. In-place filtering of files in a directory
196+
that other, untrusted users can modify is not supported.
197+
198+
.. versionchanged:: next
199+
The output file is now created exclusively, and the backup file is no
200+
longer deleted if the output file cannot be created.
201+
192202

193203
The two following opening hooks are provided by this module:
194204

‎Lib/fileinput.py‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,9 @@ def nextfile(self):
281281
finally:
282282
backupfilename = self._backupfilename
283283
self._backupfilename = None
284-
if backupfilename and not self._backup:
284+
# If the output file could not be created, the backup file
285+
# is the only copy of the data: keep it.
286+
if backupfilename and not self._backup and output:
285287
try: os.unlink(backupfilename)
286288
except OSError: pass
287289

@@ -329,29 +331,34 @@ def _readline(self):
329331
if self._inplace:
330332
self._backupfilename = (
331333
os.fspath(self._filename) + (self._backup or ".bak"))
332-
try:
333-
os.unlink(self._backupfilename)
334-
except OSError:
335-
pass
336-
# The next few lines may raise OSError
337-
os.rename(self._filename, self._backupfilename)
334+
# The next few lines may raise OSError. An existing backup
335+
# file is only replaced once there is a file to back up: it
336+
# can be the only copy of the data after an earlier failure.
337+
os.replace(self._filename, self._backupfilename)
338338
self._file = open(self._backupfilename, self._mode,
339339
encoding=encoding, errors=self._errors)
340+
# The file has just been moved away, so nothing should exist
341+
# under its name: create the output file exclusively rather
342+
# than write through a file or symlink that has appeared there.
340343
try:
341344
perm = os.fstat(self._file.fileno()).st_mode
342345
except OSError:
343-
self._output = open(self._filename, self._write_mode,
346+
self._output = open(self._filename,
347+
self._write_mode.replace('w', 'x'),
344348
encoding=encoding, errors=self._errors)
345349
else:
346-
mode = os.O_CREAT | os.O_WRONLY | os.O_TRUNC
350+
mode = os.O_CREAT | os.O_WRONLY | os.O_EXCL
347351
if hasattr(os, 'O_BINARY'):
348352
mode |= os.O_BINARY
349353

350354
fd = os.open(self._filename, mode, perm)
351355
self._output = os.fdopen(fd, self._write_mode,
352356
encoding=encoding, errors=self._errors)
353357
try:
354-
os.chmod(self._filename, perm)
358+
if os.chmod in os.supports_fd:
359+
os.chmod(fd, perm)
360+
else:
361+
os.chmod(self._filename, perm)
355362
except OSError:
356363
pass
357364
self._savestdout = sys.stdout

‎Lib/test/test_fileinput.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,101 @@ def test_inplace_encoding_errors(self):
335335
with open(temp_file, 'rb') as f:
336336
self.assertEqual(f.read().rstrip(b'\r\n'), b'New line ?')
337337

338+
def check_inplace_name_taken(self, take_name):
339+
# Something else gets the name of the file after it has been moved
340+
# to the backup file and before the output file is created.
341+
temp_file = self.writeTmp('Initial text.')
342+
self.addCleanup(os_helper.unlink, temp_file + '.bak')
343+
orig_replace = os.replace
344+
def replace(src, dst):
345+
orig_replace(src, dst)
346+
take_name(temp_file)
347+
with mock.patch('os.replace', replace):
348+
with self.assertRaises(FileExistsError):
349+
with FileInput(temp_file, inplace=True, encoding="utf-8") as fi:
350+
fi.readline()
351+
# The backup file is the only copy of the data, it must be kept.
352+
with open(temp_file + '.bak', encoding="utf-8") as f:
353+
self.assertEqual(f.read(), 'Initial text.')
354+
355+
@os_helper.skip_unless_symlink
356+
def test_inplace_does_not_follow_symlink(self):
357+
target = self.writeTmp('Other text.')
358+
os.chmod(target, 0o600)
359+
mode = os.stat(target).st_mode
360+
def take_name(name):
361+
os.chmod(name + '.bak', 0o644)
362+
os.symlink(target, name)
363+
self.check_inplace_name_taken(take_name)
364+
with support.swap_attr(os, 'fstat', UnconditionallyRaise(OSError)):
365+
self.check_inplace_name_taken(take_name)
366+
with open(target, encoding="utf-8") as f:
367+
self.assertEqual(f.read(), 'Other text.')
368+
self.assertEqual(os.stat(target).st_mode, mode)
369+
370+
def test_inplace_does_not_reuse_existing_file(self):
371+
names = []
372+
def take_name(name):
373+
with open(name, 'w', encoding="utf-8") as f:
374+
f.write('Other text.')
375+
names.append(name)
376+
self.check_inplace_name_taken(take_name)
377+
with support.swap_attr(os, 'fstat', UnconditionallyRaise(OSError)):
378+
self.check_inplace_name_taken(take_name)
379+
for name in names:
380+
with open(name, encoding="utf-8") as f:
381+
self.assertEqual(f.read(), 'Other text.')
382+
383+
def check_inplace_backup_kept(self, temp_file, exc_type):
384+
with self.assertRaises(exc_type):
385+
with FileInput(temp_file, inplace=True, encoding="utf-8") as fi:
386+
fi.readline()
387+
self.assertFalse(os.path.exists(temp_file))
388+
with open(temp_file + '.bak', encoding="utf-8") as f:
389+
self.assertEqual(f.read(), 'Initial text.')
390+
391+
def test_inplace_backup_kept_if_open_fails(self):
392+
# The file can be moved to the backup file, but not opened.
393+
temp_file = self.writeTmp('Initial text.')
394+
self.addCleanup(os_helper.unlink, temp_file + '.bak')
395+
with mock.patch('fileinput.open', create=True,
396+
side_effect=PermissionError):
397+
self.check_inplace_backup_kept(temp_file, PermissionError)
398+
# Trying again must not delete the backup file either.
399+
self.check_inplace_backup_kept(temp_file, FileNotFoundError)
400+
self.check_inplace_backup_kept(temp_file, FileNotFoundError)
401+
402+
def test_inplace_backup_kept_if_output_not_created(self):
403+
temp_file = self.writeTmp('Initial text.')
404+
self.addCleanup(os_helper.unlink, temp_file + '.bak')
405+
with mock.patch('os.open', side_effect=PermissionError):
406+
self.check_inplace_backup_kept(temp_file, PermissionError)
407+
self.check_inplace_backup_kept(temp_file, FileNotFoundError)
408+
self.check_inplace_backup_kept(temp_file, FileNotFoundError)
409+
410+
@os_helper.skip_unless_symlink
411+
@unittest.skipUnless(os.chmod in os.supports_fd,
412+
"os.chmod() does not support file descriptors")
413+
@unittest.skipIf(sys.platform == 'win32',
414+
"cannot unlink an open file on Windows")
415+
def test_inplace_chmod_does_not_follow_symlink(self):
416+
# The output file is replaced by a symlink after it has been created
417+
# and before its permissions are set.
418+
target = self.writeTmp('Other text.')
419+
os.chmod(target, 0o600)
420+
mode = os.stat(target).st_mode
421+
temp_file = self.writeTmp('Initial text.')
422+
os.chmod(temp_file, 0o644)
423+
orig_fdopen = os.fdopen
424+
def fdopen(fd, *args, **kwargs):
425+
os.unlink(temp_file)
426+
os.symlink(target, temp_file)
427+
return orig_fdopen(fd, *args, **kwargs)
428+
with mock.patch('os.fdopen', fdopen):
429+
with FileInput(temp_file, inplace=True, encoding="utf-8") as fi:
430+
fi.readline()
431+
self.assertEqual(os.stat(target).st_mode, mode)
432+
338433
def test_file_hook_backward_compatibility(self):
339434
def old_hook(filename, mode):
340435
return io.StringIO("I used to receive only filename and mode")
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fix in-place filtering in :mod:`fileinput` deleting the file when it cannot
2+
be opened or the output file cannot be created: the backup file is now kept.
3+
The output file is now created exclusively, so :exc:`FileExistsError` is
4+
raised if a file or symbolic link appears under the name of the input file.

0 commit comments

Comments
 (0)