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
8 changes: 6 additions & 2 deletions src/filelock/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ def raise_on_not_writable_file(filename: str) -> None:
:raises OSError: as if the file was opened for writing.

"""
try: # use stat to do exists + can write to check without race condition
file_stat = os.stat(filename) # noqa: PTH116
try: # use lstat to do exists + can write to check without race condition
# lstat, not stat: a hostile symlink planted at the lock path would otherwise make this check inspect the
# link target, so an attacker could turn a contended acquire into a misleading PermissionError /
# IsADirectoryError and probe the target's attributes. The actual open uses O_NOFOLLOW and refuses the
# symlink anyway, so reading the link itself here keeps the handling consistent with the rest of the module.
file_stat = os.lstat(filename)
except OSError:
return # swallow does not exist or other errors

Expand Down
54 changes: 53 additions & 1 deletion tests/test_util.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from __future__ import annotations

import os
import stat
import sys
from typing import TYPE_CHECKING

import pytest

from filelock._util import break_lock_file
from filelock._util import break_lock_file, raise_on_not_writable_file

if TYPE_CHECKING:
from pathlib import Path
Expand Down Expand Up @@ -64,3 +66,53 @@ def test_break_lock_file_aborts_if_break_path_vanishes(tmp_path: Path, mocker: M
def test_break_lock_file_missing_source_raises(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
break_lock_file(str(tmp_path / "nope.lock"), 0.0, 0)


@pytest.mark.skipif(sys.platform == "win32", reason="symlink-to-dir raises IsADirectoryError only on Unix")
def test_raise_on_not_writable_file_does_not_follow_symlink_to_dir(tmp_path: Path) -> None:
target = tmp_path / "targetdir"
target.mkdir()
link = tmp_path / "my.lock"
link.symlink_to(target)
# Following the symlink would see a directory and raise IsADirectoryError; lstat sees the link itself.
raise_on_not_writable_file(str(link))
assert stat.S_ISLNK(os.lstat(link).st_mode)


@pytest.mark.skipif(sys.platform == "win32", reason="symlink + 0o444 semantics differ on Windows")
@pytest.mark.skipif(
sys.platform != "win32" and os.geteuid() == 0,
reason="root can write a 0o444 file, so following the symlink would not raise",
)
def test_raise_on_not_writable_file_does_not_follow_symlink_to_readonly(tmp_path: Path) -> None:
target = tmp_path / "readonly"
target.write_text("x", encoding="utf-8")
target.chmod(0o444)
link = tmp_path / "my.lock"
link.symlink_to(target)
# Following the symlink would see a read-only file and raise PermissionError; the link itself is writable.
raise_on_not_writable_file(str(link))


@pytest.mark.skipif(sys.platform == "win32", reason="real dir raises PermissionError on Windows")
def test_raise_on_not_writable_file_still_rejects_real_directory(tmp_path: Path) -> None:
path = tmp_path / "a_dir"
path.mkdir()
with pytest.raises(IsADirectoryError):
raise_on_not_writable_file(str(path))


@pytest.mark.skipif(sys.platform == "win32", reason="Windows does not have read only files in the same way")
@pytest.mark.skipif(
sys.platform != "win32" and os.geteuid() == 0,
reason="root can write a 0o444 file",
)
def test_raise_on_not_writable_file_still_rejects_readonly_file(tmp_path: Path) -> None:
path = tmp_path / "ro.lock"
path.write_text("x", encoding="utf-8")
path.chmod(0o444)
try:
with pytest.raises(PermissionError):
raise_on_not_writable_file(str(path))
finally:
path.chmod(0o644)