Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions src/filelock/_soft.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import socket
import stat
import sys
import time
from contextlib import suppress
Expand Down Expand Up @@ -200,14 +201,22 @@ def _windows_unlink_with_retry(self) -> None:


def _read_lock_file(path: str) -> tuple[str | None, float, int]:
# The lock file is created with O_EXCL | O_NOFOLLOW, so a symlink here is a hostile replacement and must
# not be followed. O_NONBLOCK keeps an attacker-placed FIFO from stalling the open (O_NOFOLLOW alone only
# rejects a symlink, not a real FIFO at the path), and the capped read stops a huge file (e.g. /dev/zero)
# from exhausting memory. Content is None when the file is too large or not UTF-8, but the mtime and inode
# still flow back so the caller can evict it as a stale, malformed lock and verify identity before breaking.
# A legitimate lock file is always a regular file. Classify the path with lstat first, so any other node (symlink,
# FIFO, socket, device) is reported as a malformed lock the caller can evict, without an os.open that would follow
# a symlink, stall on a FIFO, or fail on a socket and leave acquisition wedged. The mtime and inode still flow back
# for the identity-checked stale break. lstat, not stat, so a hostile symlink is never followed onto its target.
st = os.lstat(path)
if not stat.S_ISREG(st.st_mode):
return None, st.st_mtime, st.st_ino
# Re-check on the opened handle: O_NOFOLLOW refuses a symlink swapped in after the lstat, O_NONBLOCK stops a FIFO
# swapped in from stalling the open, and the fstat catches any other non-regular replacement race before we read.
# The capped read stops a huge regular file (e.g. one filled from /dev/zero) from exhausting memory.
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
try:
st, data = os.fstat(fd), os.read(fd, _MAX_LOCK_FILE_SIZE + 1)
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode): # pragma: no cover # only a non-regular node swapped in after the lstat
return None, st.st_mtime, st.st_ino
data = os.read(fd, _MAX_LOCK_FILE_SIZE + 1)
finally:
os.close(fd)
if len(data) <= _MAX_LOCK_FILE_SIZE:
Expand Down
34 changes: 34 additions & 0 deletions tests/test_soft_stale.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,40 @@ def test_fifo_lock_file_does_not_block(lock_path: Path) -> None:
assert SoftFileLock(lock_path).pid is None


def test_fifo_lock_file_with_attached_writer_self_heals(lock_path: Path) -> None:
if sys.platform == "win32":
pytest.skip("os.mkfifo is unix-only")
# A same-UID peer can plant a FIFO with a writer attached so a non-blocking read would raise EAGAIN. The lstat
# guard classifies it as a malformed lock before any open, so an aged FIFO self-heals like any other node.
os.mkfifo(lock_path)
reader = os.open(lock_path, os.O_RDONLY | os.O_NONBLOCK)
writer = os.open(lock_path, os.O_WRONLY | os.O_NONBLOCK) # attached but never written, so reads get EAGAIN
try:
os.utime(lock_path, (0, 0))
_assert_self_heals(lock_path)
finally:
os.close(reader)
os.close(writer)


def test_socket_lock_file_self_heals(lock_path: Path) -> None:
if sys.platform == "win32":
pytest.skip("AF_UNIX sockets are unix-only")
sock = socket.socket(socket.AF_UNIX)
try:
sock.bind(str(lock_path))
except OSError:
sock.close()
pytest.skip("AF_UNIX path too long for this temp dir")
# A Unix-domain socket cannot be os.open()ed as a file. Before the lstat guard the failed open was swallowed by
# stale detection so acquisition wedged; an aged socket now self-heals like any other non-regular node.
try:
os.utime(lock_path, (0, 0))
_assert_self_heals(lock_path)
finally:
sock.close()


def test_stale_detection_errors_suppressed(lock_path: Path, mocker: MockerFixture) -> None:
lock_path.write_text(_holder(os.getpid()), encoding="utf-8")
mock_read: MagicMock = mocker.patch("filelock._soft._read_lock_file", side_effect=OSError("read failed"))
Expand Down