diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py index c2da6973..413ce3bc 100644 --- a/src/filelock/_soft.py +++ b/src/filelock/_soft.py @@ -2,6 +2,7 @@ import os import socket +import stat import sys import time from contextlib import suppress @@ -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: diff --git a/tests/test_soft_stale.py b/tests/test_soft_stale.py index 42ad3eb0..994bb73d 100644 --- a/tests/test_soft_stale.py +++ b/tests/test_soft_stale.py @@ -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"))