new file mode 100644
@@ -0,0 +1,312 @@
+From e1e825b1992f69fb6b346dc395d5ae4893536c0a Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= <gaborjbernat@gmail.com>
+Date: Sat, 17 Oct 2026 03:47:58 -0700
+Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(open):=20keep=20lock=20files?=
+ =?UTF-8?q?=20intact,=20honor=20deadlines=20and=20mode=20(#835)?=
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+CVE: CVE-2026-112909
+Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/e1e825b1992f69fb6b346dc395d5ae4893536c0a]
+
+Backport Changes:
+- Backport only the UnixFileLock file-integrity changes and regressions.
+- Adapt acquisition, creation detection, cleanup and comments to the
+ 3.25.2 context and helper layout; keep its pathname, FUSE/NFS retries
+ and unlink-on-release behavior.
+- Reuse remove_own_file() from the preceding helper backport for ENOSYS
+ cleanup while the created descriptor still pins its inode.
+- Include the own-placeholder ENOSYS handling and regressions from
+ commit 17a7c3182674601998588eb7aad6ec765c959251, plus the
+ placeholder-swap regression from the preceding helper backport.
+- Omit unrelated lease/oplock deadline and Windows permission changes,
+ newer descriptor/read-write/strict/soft plumbing and its tests, plus
+ associated build, task and documentation changes.
+- Use this suite's Unix marks, inline flock mocks and two-line
+ PID/hostname format instead of newer capability/identity helpers.
+
+Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ src/filelock/_unix.py | 82 +++++++++++++++++++++++--------------
+ tests/test_filelock.py | 45 +++++++++++++++++++-
+ tests/test_unix_fallback.py | 79 ++++++++++++++++++++++++++++++++++-
+ 3 files changed, 171 insertions(+), 35 deletions(-)
+
+diff --git a/src/filelock/_unix.py b/src/filelock/_unix.py
+index ac5f26a..650be40 100644
+--- a/src/filelock/_unix.py
++++ b/src/filelock/_unix.py
+@@ -9,7 +9,7 @@ from pathlib import Path
+ from typing import cast
+
+ from ._api import BaseFileLock
+-from ._util import ensure_directory_exists
++from ._util import ensure_directory_exists, remove_own_file
+
+ #: a flag to indicate if the fcntl API is available
+ has_fcntl = False
+@@ -45,52 +45,72 @@ else: # pragma: win32 no cover
+
+ def _acquire(self) -> None: # noqa: C901, PLR0912
+ ensure_directory_exists(self.lock_file)
+- open_flags = os.O_RDWR | os.O_TRUNC
++ if (opened := self._open_lock_file()) is None:
++ return
++ fd, created = opened
++ keep_fd = False
++ missing_flock = False
++ try:
++ if created and self.has_explicit_mode:
++ # Before the flock: a peer may win it first, and only the creator may set the mode.
++ # An fchmod that answers ENOSYS says nothing about flock.
++ with suppress(PermissionError):
++ os.fchmod(fd, self._context.mode)
++ try:
++ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
++ except OSError as exception:
++ if exception.errno == ENOSYS:
++ # Keep the descriptor open so a peer cannot reuse this inode while its own placeholder
++ # is removed. An existing file may be a peer's live marker and must stay.
++ if created:
++ with suppress(OSError):
++ remove_own_file(self.lock_file, fd)
++ missing_flock = True
++ elif exception.errno not in {EAGAIN, EWOULDBLOCK}:
++ raise
++ else:
++ # A lock on an inode unlinked between open() and flock() guards no name. Retry it.
++ if os.fstat(fd).st_nlink != 0:
++ self._context.lock_file_fd = fd
++ keep_fd = True
++ except BaseException:
++ self._context.lock_file_fd = None
++ keep_fd = False
++ raise
++ finally:
++ if not keep_fd:
++ os.close(fd)
++ if missing_flock:
++ self._fallback_to_soft_lock()
++ self._acquire()
++
++ def _open_lock_file(self) -> tuple[int, bool] | None:
++ # A renamed or hard-linked regular file is indistinguishable from an existing lock file: do not
++ # truncate it. O_EXCL identifies a file this acquire created, so only that file receives its mode.
++ open_flags = os.O_RDWR
+ o_nofollow = getattr(os, "O_NOFOLLOW", None)
+ if o_nofollow is not None:
+ open_flags |= o_nofollow
+- open_flags |= os.O_CREAT
+ open_mode = self._open_mode()
+ try:
+- fd = os.open(self.lock_file, open_flags, open_mode)
++ return os.open(self.lock_file, open_flags | os.O_CREAT | os.O_EXCL, open_mode), True
++ except FileExistsError:
++ pass
+ except FileNotFoundError:
+ # On FUSE/NFS, os.open(O_CREAT) is not atomic: LOOKUP + CREATE can be split, allowing a concurrent
+ # unlink() to delete the file between them. For valid paths, treat ENOENT as transient contention.
+ # For invalid paths (e.g., empty string), re-raise to avoid infinite retry loops.
+ if self.lock_file and Path(self.lock_file).parent.exists():
+- return
++ return None
+ raise
+ except PermissionError:
+ # Sticky-bit dirs (e.g. /tmp): O_CREAT fails if the file is owned by another user (#317).
+- # Fall back to opening the existing file without O_CREAT.
+ if not Path(self.lock_file).exists():
+ raise
+- try:
+- fd = os.open(self.lock_file, open_flags & ~os.O_CREAT, open_mode)
+- except FileNotFoundError:
+- return
+- if self.has_explicit_mode:
+- with suppress(PermissionError):
+- os.fchmod(fd, self._context.mode)
+ try:
+- fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+- except OSError as exception:
+- os.close(fd)
+- if exception.errno == ENOSYS:
+- with suppress(OSError):
+- Path(self.lock_file).unlink()
+- self._fallback_to_soft_lock()
+- self._acquire()
+- return
+- if exception.errno not in {EAGAIN, EWOULDBLOCK}:
+- raise
+- else:
+- # The file may have been unlinked by a concurrent _release() between our open() and flock().
+- # A lock on an unlinked inode is useless — discard and let the retry loop start fresh.
+- if os.fstat(fd).st_nlink == 0:
+- os.close(fd)
+- else:
+- self._context.lock_file_fd = fd
++ return os.open(self.lock_file, open_flags, open_mode), False
++ except FileNotFoundError:
++ return None
+
+ def _fallback_to_soft_lock(self) -> None:
+ from ._soft import SoftFileLock # noqa: PLC0415
+diff --git a/tests/test_filelock.py b/tests/test_filelock.py
+index 38c7b90..b733f1b 100644
+--- a/tests/test_filelock.py
++++ b/tests/test_filelock.py
+@@ -7,10 +7,10 @@ import sys
+ import threading
+ from concurrent.futures import ThreadPoolExecutor
+ from contextlib import contextmanager
+-from errno import ENOSYS
++from errno import EIO, ENOSYS, EWOULDBLOCK
+ from inspect import getframeinfo, stack
+ from pathlib import Path, PurePath
+-from stat import S_IWGRP, S_IWOTH, S_IWUSR, filemode
++from stat import S_IMODE, S_IWGRP, S_IWOTH, S_IWUSR, filemode
+ from types import TracebackType
+ from typing import TYPE_CHECKING, Any
+ from uuid import uuid4
+@@ -1146,3 +1146,44 @@ def test_filenotfound_on_fuse_nfs_retries(tmp_path: Path, mocker: MockerFixture)
+ # First call failed with ENOENT, retry succeeded
+ assert call_count >= 2
+ lock.release()
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Unix flock semantics")
++@pytest.mark.parametrize("mode", [pytest.param(-1, id="default-mode"), pytest.param(0o666, id="explicit-mode")])
++def test_winner_leaves_existing_file_as_found(tmp_path: Path, mode: int) -> None:
++ (journal := tmp_path / "journal.log").write_text("data", encoding="utf-8")
++ journal.chmod(0o600)
++ # A regular file with one name is indistinguishable from a lock file an earlier holder left.
++ journal.rename(lock_path := tmp_path / "resource.lock")
++
++ with FileLock(lock_path, timeout=0, mode=mode):
++ assert (lock_path.read_text(encoding="utf-8"), S_IMODE(lock_path.stat().st_mode)) == ("data", 0o600)
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Unix flock semantics")
++def test_creator_that_loses_the_flock_applies_mode(tmp_path: Path, mocker: MockerFixture) -> None:
++ lock_path = tmp_path / "resource.lock"
++ # A peer may win the flock first, and only the creator may set the mode.
++ mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(EWOULDBLOCK, "Resource temporarily unavailable"))
++
++ initial_umask = os.umask(0o077)
++ try:
++ with pytest.raises(Timeout):
++ FileLock(lock_path, timeout=0, mode=0o666).acquire()
++ finally:
++ os.umask(initial_umask)
++
++ assert S_IMODE(lock_path.stat().st_mode) == 0o666
++
++
++@pytest.mark.skipif(sys.platform == "win32", reason="Unix flock semantics")
++@pytest.mark.parametrize("failing", [pytest.param("fchmod", id="fchmod"), pytest.param("fstat", id="fstat")])
++def test_post_open_failure_closes_fd(tmp_path: Path, mocker: MockerFixture, failing: str) -> None:
++ mocker.patch(f"os.{failing}", side_effect=OSError(EIO, "Input/output error"))
++ open_spy = mocker.spy(os, "open")
++ close_spy = mocker.spy(os, "close")
++
++ with pytest.raises(OSError, match="Input/output error"):
++ FileLock(tmp_path / "resource.lock", timeout=0, mode=0o644).acquire()
++
++ assert any(call.args and call.args[0] == open_spy.spy_return for call in close_spy.call_args_list)
+diff --git a/tests/test_unix_fallback.py b/tests/test_unix_fallback.py
+index b99c716..d48a1f6 100644
+--- a/tests/test_unix_fallback.py
++++ b/tests/test_unix_fallback.py
+@@ -3,12 +3,13 @@ from __future__ import annotations
+ import os
+ import socket
+ import sys
++import warnings
+ from errno import EIO, ENOSYS
+-from typing import TYPE_CHECKING
++from typing import TYPE_CHECKING, Final
+
+ import pytest
+
+-from filelock import SoftFileLock, UnixFileLock
++from filelock import SoftFileLock, Timeout, UnixFileLock
+
+ if TYPE_CHECKING:
+ from pathlib import Path
+@@ -111,3 +112,77 @@ def test_release_suppresses_eio_on_close(tmp_path: Path, mocker: MockerFixture)
+ mocker.patch("filelock._unix.os.close", side_effect=_close_eio)
+ lock.release()
+ assert not lock.is_locked
++
++
++@unix_only
++def test_fchmod_enosys_is_not_a_missing_flock(tmp_path: Path, mocker: MockerFixture) -> None: # pragma: needs fcntl
++ mocker.patch("os.fchmod", side_effect=OSError(ENOSYS, "Function not implemented"))
++ lock = UnixFileLock(tmp_path / "test.lock", mode=0o644)
++
++ with warnings.catch_warnings(record=True) as recorded:
++ warnings.simplefilter("always")
++ with pytest.raises(OSError, match="Function not implemented"):
++ lock.acquire()
++
++ assert not recorded
++ assert not isinstance(lock, SoftFileLock)
++
++
++@unix_only
++@pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning")
++def test_fallback_removes_placeholder_it_created(tmp_path: Path, mocker: MockerFixture) -> None: # pragma: needs fcntl
++ mocker.patch(_FLOCK_PATCH_TARGET, side_effect=_ENOSYS_SIDE_EFFECT)
++ lock = UnixFileLock(tmp_path / "test.lock")
++
++ lock.acquire(timeout=0)
++ assert lock.is_locked
++ lock.release()
++
++
++@unix_only
++@pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning")
++def test_fallback_keeps_peer_soft_marker(tmp_path: Path, mocker: MockerFixture) -> None: # pragma: needs fcntl
++ mocker.patch(_FLOCK_PATCH_TARGET, side_effect=_ENOSYS_SIDE_EFFECT)
++ lock_path = tmp_path / "test.lock"
++ holder = UnixFileLock(lock_path)
++ holder.acquire()
++ marker = lock_path.stat().st_ino, lock_path.read_bytes()
++
++ with pytest.raises(Timeout):
++ UnixFileLock(lock_path).acquire(timeout=0)
++ assert (lock_path.stat().st_ino, lock_path.read_bytes()) == marker
++ holder.release()
++
++
++@unix_only
++@pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning")
++def test_fallback_spares_a_peer_marker_that_replaced_its_placeholder( # pragma: needs fcntl
++ tmp_path: Path, mocker: MockerFixture
++) -> None:
++ lock_path: Final = tmp_path / "test.lock"
++ peer_marker: Final = f"{os.getpid()}\n{socket.gethostname()}\n"
++ lstat: Final = os.lstat
++ stalled: list[bool] = []
++
++ def flock_missing(fd: int, operation: int) -> None:
++ stalled.append(False)
++ raise OSError(ENOSYS, f"Function not implemented for {fd} with {operation}")
++
++ def lstat_then_peer_replaces_placeholder(path: str, *, dir_fd: int | None = None) -> os.stat_result:
++ found: Final = lstat(path, dir_fd=dir_fd)
++ if stalled == [False] and os.fspath(path) == os.fspath(lock_path):
++ # The cleanup stalls past the grace a waiting soft lock gives an empty file, which evicts the placeholder
++ # and publishes its own marker.
++ stalled[0] = True
++ lock_path.unlink()
++ lock_path.write_text(peer_marker, encoding="utf-8")
++ return found
++
++ mocker.patch("filelock._unix.fcntl.flock", side_effect=flock_missing)
++ mocker.patch("os.lstat", side_effect=lstat_then_peer_replaces_placeholder)
++ with pytest.raises(Timeout):
++ UnixFileLock(lock_path).acquire(timeout=0)
++ mocker.stopall()
++
++ # Where os.link cannot restore it, the peer's marker waits under a break name, but it is never deleted.
++ assert peer_marker in [path.read_text(encoding="utf-8") for path in tmp_path.glob("test.lock*")]
new file mode 100644
@@ -0,0 +1,53 @@
+From 65f5ea039c30ac6a77f09a88c1920503bff10a82 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= <gaborjbernat@gmail.com>
+Date: Tue, 27 Oct 2026 21:01:48 -0700
+Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20test:=20narrow=20fstat=20mocks?=
+ =?UTF-8?q?=20to=20the=20lock's=20descriptor=20(#893)?=
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+CVE: CVE-2026-112909
+Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/65f5ea039c30ac6a77f09a88c1920503bff10a82]
+
+Backport Changes:
+- Retain only the test_post_open_failure_closes_fd() correction; the
+ other newer test modules and cases are absent from this backport.
+- Add the required Final import and retain the target Unix skip mark.
+
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ tests/test_filelock.py | 11 +++++++++--
+ 1 file changed, 9 insertions(+), 2 deletions(-)
+
+diff --git a/tests/test_filelock.py b/tests/test_filelock.py
+index b733f1b..0479c85 100644
+--- a/tests/test_filelock.py
++++ b/tests/test_filelock.py
+@@ -12,7 +12,7 @@ from inspect import getframeinfo, stack
+ from pathlib import Path, PurePath
+ from stat import S_IMODE, S_IWGRP, S_IWOTH, S_IWUSR, filemode
+ from types import TracebackType
+-from typing import TYPE_CHECKING, Any
++from typing import TYPE_CHECKING, Any, Final
+ from uuid import uuid4
+ from weakref import WeakValueDictionary
+
+@@ -1179,9 +1179,16 @@ def test_creator_that_loses_the_flock_applies_mode(tmp_path: Path, mocker: Mocke
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix flock semantics")
+ @pytest.mark.parametrize("failing", [pytest.param("fchmod", id="fchmod"), pytest.param("fstat", id="fstat")])
+ def test_post_open_failure_closes_fd(tmp_path: Path, mocker: MockerFixture, failing: str) -> None:
+- mocker.patch(f"os.{failing}", side_effect=OSError(EIO, "Input/output error"))
+ open_spy = mocker.spy(os, "open")
+ close_spy = mocker.spy(os, "close")
++ real: Final = getattr(os, failing)
++
++ def fail_for_the_lock_file(fd: int, *args: int) -> os.stat_result | None:
++ if fd == open_spy.spy_return:
++ raise OSError(EIO, "Input/output error")
++ return real(fd, *args)
++
++ mocker.patch(f"os.{failing}", side_effect=fail_for_the_lock_file)
+
+ with pytest.raises(OSError, match="Input/output error"):
+ FileLock(tmp_path / "resource.lock", timeout=0, mode=0o644).acquire()
@@ -12,6 +12,8 @@ SRC_URI += " file://run-ptest \
file://CVE-2026-112935_p1.patch \
file://CVE-2026-112935_p2.patch \
file://CVE-2026-112935_p3.patch \
+ file://CVE-2026-112909_p1.patch \
+ file://CVE-2026-112909_p2.patch \
"
SRC_URI[sha256sum] = "b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"