From patchwork Wed Nov 11 05:29:30 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" X-Patchwork-Id: 103659 Received: from rcdn-iport-4.cisco.com (rcdn-iport-4.cisco.com [173.37.86.75]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.3364.1794374983916368146 for ; Tue, 10 Nov 2026 21:29:44 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=VrmWGz2W; spf=pass (domain: cisco.com, ip: 173.37.86.75, mailfrom: dkelaiya@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=5776; q=dns/txt; s=iport01; t=1794374983; x=1795584583; h=from:to:cc:subject:date:message-id:in-reply-to: references:mime-version:content-transfer-encoding; bh=qPohO+U92fHfJA9TQa9Ww5vCv+51F8nyHpv6RGDilkY=; b=VrmWGz2Wgwv9ed07XNeVY524kc+ApXxFX8WsCoNs3JU5KacT9RiBomSW 6TrLJST4kB/bEt8hWw8jZm2WSt9u50N541Zsc6luksqmxCq5a3GGKZVTf jkK1eQwEkW9ilNTtB6eHDt4aQzYlDBGjXWf+KvJgfmGqImY/zmM1EF6NK AjyG5ICaBDIO+j6AGNorgRNvXblFHymTNb/rXq+lGOHxF8kaJoB/jO3aY 0OkyL08u9q4UzxrIzqmKwNJyO04QnaKrKcBnoJtekU7ti6ozQ4z1Dmp99 +/yKizv1U/aFUXcDWcmtk1xPhGrpSF3R2ghOpNOKVN7s0F2xiocFAHwZs w==; X-CSE-ConnectionGUID: 4CJcE3QUT2uE0clZ8SjdyQ== X-CSE-MsgGUID: aqAxdrFtQKqJzo/Z1zowMQ== X-IPAS-Result: A0BNAgDN/PNq/43/Ja1RCYJZgld1YENJA5VcbAOLZZI9FIFqDwEBAQ9EDQQBAYQ/RgKOHgImNAkOAQIEAwIDAQEBAQEBAQEBAQEBCgEBBQEBAQIBBwWBDhOGTw2QEgECAQMnCwEYAS0QHAMBAhYZIAsdAQUIEAmDAgGCOgM3AxG5XoF5M4EBgykBgVTYTA2CWgELFAGBOIVCgwCFJF0YAYR9JxsbgXKBFYNpgQWBGkICgRkODWuFZSIEgiKBDIFajRoCgU+HJUiBHgNZLAFVEw0KCwcFgWgDNRIqFW4yHYEjPheBBxsGBYEcgR6BEoEHXYErKF0BIYEIgXYCglGCAAIBSUEOB0VTCSVDEkckIggSCQETGiwKgRo1PAkmPxUMZwsaBWEIAg0MdDkQMB4kLwUaJj4bCn4YDUgRLDcVGQQ+bgeSboIRgQSBDgEmBVQ9CBYUKUBOkmkIkjegIHEKKIN4jCSPPoV8GjOqcguYfY4KhAqRek2EaoFoPIFZcBWDIglKGQ9XjVgKC4NgzlsmMgIJAy8BAQcCBw4DC4FokACBfgEB IronPort-Data: A9a23:4ogClat0w+e0tbCPdKOGEN8qEefnVAZfMUV32f8akzHdYApBsoF/q tZmKW2BPPaKYjTxLt1+O4myp0IFu5WAnNY2HFBkqSA0FXlGgMeUXt7xwmUckM+xwmwvaGo9s q3yv/GZdJhcokf0/0nrb/656yYsjclkf5KkYMbcICd9WAR4fykojBNnioYRj5Vh6TSDK1vlV eja/YuFZzdJ5xYuajhKsvva8Us21BjPkGpwUmIWNKgjUGD2zxH5PLpHTYmtIn3xRJVjH+LSb 47r0LGj82rFyAwmA9Wjn6yTWhVirmn6ZFXmZtJ+AsBOszAazsAA+v9T2Mk0NS+7vw60c+VZk 72hg3AfpTABZcUgkMxFO/VR/roX0aduoNcrKlDn2SCfItGvn3bEm51T4E8K0YIwps1VCkwVz MEkci0gNhuHtbKNwr6qc7w57igjBJGD0II3oHpsy3TdSP0hW52GG/mM7t5D1zB2jcdLdRrcT 5NGMnw0M1KaPkAJYwtOYH49tL/Aan3Xfz5VrFuUtKMf6GnIxws327/oWDbQUoHWG5gPzx7C+ Qoq+UzwGzgKOMax1QOm61C+gaj9xDH8R6crQejQGvlCxQf7KnYoIBoOWF22pPO0hkKzV5dTJ lIZ/gIqrLMu7wqsVtT7UhiyrXKIsxJaXMBfe9DW8ymXwabSpgLcDW8eQ3sZNJottdQ9Qnoh0 Vrhc87VOAGDeYa9ERq1nop4ZxvrUcTJBQfuvRM5cDY= IronPort-HdrOrdr: A9a23:Lng+O6PjwzQJIMBcTuOjsMiBIKoaSvp037Dk7S9MoHtuA6ulfq +V/cjzuSWYtN9VYgBDpTniAtjlfZqjz/5ICOAqVN/INjUO+lHYSb2KhrGN/9SPIUHDH5ZmpM RdWpk7LsHsBl5nisu/ygy5H9E8hOSjysmT9IHjJ7MHd3ATV0mmhD0JczqmLg== X-Talos-CUID: 9a23:3mvy22zeCTGanSDTniNYBgVIMeIuXybk8UuKPmLgAmJbGIaXRU6prfY= X-Talos-MUID: 9a23:h1RHOQ+Rv5TufgdT7/kk/AOQf85xyrSiEm4RqLYfmduDch4pKTCCqQ3iFw== X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,207,1787011200"; d="scan'208";a="546529027" Received: from rcdn-l-core-04.cisco.com ([173.37.255.141]) by rcdn-iport-4.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 11 Nov 2026 05:29:43 +0000 Received: from sjc-ads-5675.cisco.com (sjc-ads-5675.cisco.com [10.28.88.189]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by rcdn-l-core-04.cisco.com (Postfix) with ESMTPS id F3D87180001B5; Wed, 11 Nov 2026 05:29:42 +0000 (GMT) Received: by sjc-ads-5675.cisco.com (Postfix, from userid 1887444) id 9E771CBF201; Tue, 10 Nov 2026 21:29:42 -0800 (PST) From: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" To: openembedded-devel@lists.openembedded.org Cc: Darsh Kelaiya Subject: [oe][meta-python][wrynose][PATCH 1/9] python3-filelock: fix CVE-2026-112939 Date: Tue, 10 Nov 2026 21:29:30 -0800 Message-Id: <20261111052938.3691463-2-dkelaiya@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261111052938.3691463-1-dkelaiya@cisco.com> References: <20261111052938.3691463-1-dkelaiya@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: ANONYMOUS;sjc-ads-5675.cisco.com [10.28.88.189];TLSv1.3;TLS_AES_256_GCM_SHA384;256 X-Outbound-SMTP-Client: 10.28.88.189, sjc-ads-5675.cisco.com X-Outbound-Node: rcdn-l-core-04.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Wed, 11 Nov 2026 05:29:50 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/132113 From: Darsh Kelaiya Backport the SoftFileLock owner-permission fix [1], as identified by advisory [2]. Prevent other accounts from modifying the marker's recorded holder while preserving the existing two-line marker format. Retain the permission regression adapted to the 3.25.2 test harness. [1] https://github.com/tox-dev/filelock/commit/eeceaa846cbe4df72718d94c8371ca1ab8f5b3cc [2] https://github.com/tox-dev/filelock/security/advisories/GHSA-rfpc-qcw5-3pc4 Signed-off-by: Darsh Kelaiya --- .../python3-filelock/CVE-2026-112939.patch | 107 ++++++++++++++++++ .../python/python3-filelock_3.25.2.bb | 4 +- 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112939.patch diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112939.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112939.patch new file mode 100644 index 0000000000..621f704837 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112939.patch @@ -0,0 +1,107 @@ +From eeceaa846cbe4df72718d94c8371ca1ab8f5b3cc Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 02:21:17 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(soft):=20keep=20live=20holde?= + =?UTF-8?q?rs=20when=20owner=20identity=20differs=20(#836)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-112939 +Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/eeceaa846cbe4df72718d94c8371ca1ab8f5b3cc] + +Backport Changes: +- Retain the 3.25 mode-opening API and mask group/other write bits. +- Omit newer identity, namespace and Windows helper changes because + those interfaces are absent from the older two-line marker protocol. +- Omit their newer tests and documentation; adapt the upstream mode + regression to the existing platform checks. + +Signed-off-by: Darsh Kelaiya +--- + src/filelock/_soft.py | 7 ++++++- + tests/test_filelock.py | 31 ++++++++++++++++--------------- + 2 files changed, 22 insertions(+), 16 deletions(-) + +diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py +index 8e2cde5..3a535ea 100644 +--- a/src/filelock/_soft.py ++++ b/src/filelock/_soft.py +@@ -2,15 +2,20 @@ from __future__ import annotations + + import os + import socket ++import stat + import sys + import time + from contextlib import suppress + from errno import EACCES, EEXIST, EPERM, ESRCH + from pathlib import Path ++from typing import Final + + from ._api import BaseFileLock + from ._util import ensure_directory_exists, raise_on_not_writable_file + ++#: Contenders act on the record in the marker, so no other account may write it, whatever the mode or umask. ++_OTHERS_WRITE: Final[int] = stat.S_IWGRP | stat.S_IWOTH ++ + _WIN_SYNCHRONIZE = 0x100000 + _WIN_ERROR_INVALID_PARAMETER = 87 + +@@ -41,7 +46,7 @@ class SoftFileLock(BaseFileLock): + if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: + flags |= o_nofollow + try: +- file_handler = os.open(self.lock_file, flags, self._open_mode()) ++ file_handler = os.open(self.lock_file, flags, self._open_mode() & ~_OTHERS_WRITE) + except OSError as exception: + if not ( + exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32") +diff --git a/tests/test_filelock.py b/tests/test_filelock.py +index b7751c5..38c7b90 100644 +--- a/tests/test_filelock.py ++++ b/tests/test_filelock.py +@@ -586,27 +586,28 @@ def test_lock_mode(tmp_path: Path) -> None: + lock.release() + + +-def test_lock_mode_soft(tmp_path: Path) -> None: +- # test soft lock permissions are dependent of umask ++@pytest.mark.skipif(sys.platform == "win32", reason="POSIX marker permission bits") ++@pytest.mark.parametrize( ++ ("mode", "umask"), ++ [ ++ pytest.param(0o666, 0o022, id="explicit-mode"), ++ pytest.param(None, 0, id="default-mode-open-umask"), ++ ], ++) ++def test_lock_mode_soft_lets_only_the_owner_write_the_marker( ++ mode: int | None, umask: int, tmp_path: Path ++) -> None: ++ # Contenders act on the record inside, so no other account may rewrite it. + lock_path = tmp_path / "a.lock" +- lock = SoftFileLock(str(lock_path), mode=0o666) ++ lock = SoftFileLock(str(lock_path)) if mode is None else SoftFileLock(str(lock_path), mode=mode) + +- # set umask so permissions can be anticipated +- initial_umask = os.umask(0o022) ++ initial_umask = os.umask(umask) + try: +- lock.acquire() +- assert lock.is_locked +- +- mode = filemode(lock_path.stat().st_mode) +- if sys.platform == "win32": +- assert mode == "-rw-rw-rw-" +- else: +- assert mode == "-rw-r--r--" ++ with lock: ++ assert filemode(lock_path.stat().st_mode) == "-rw-r--r--" + finally: + os.umask(initial_umask) + +- lock.release() +- + + def test_umask(tmp_path: Path) -> None: + lock_path = tmp_path / "a.lock" diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb index 9308674f9a..03c23092b9 100644 --- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb +++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb @@ -7,7 +7,9 @@ LIC_FILES_CHKSUM = "file://LICENSE;md5=2c6acbdf7bb74caa37512c3a5ca6857b" CVE_PRODUCT = "tox-dev:filelock" -SRC_URI += "file://run-ptest" +SRC_URI += " file://run-ptest \ + file://CVE-2026-112939.patch \ +" SRC_URI[sha256sum] = "b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694" BBCLASSEXTEND = "native nativesdk" From patchwork Wed Nov 11 05:29:31 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" X-Patchwork-Id: 103666 Received: from rcdn-iport-7.cisco.com (rcdn-iport-7.cisco.com [173.37.86.78]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.3366.1794374984988995796 for ; Tue, 10 Nov 2026 21:29:45 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=Tse9vplq; spf=pass (domain: cisco.com, ip: 173.37.86.78, mailfrom: dkelaiya@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=66492; q=dns/txt; s=iport01; t=1794374985; x=1795584585; h=from:to:cc:subject:date:message-id:in-reply-to: references:mime-version:content-transfer-encoding; bh=sFcnHbCTTcbMhsVPij7B0u7vGUTYmW+Mxto2MNmm5eg=; b=Tse9vplqGXkKNSm2fhIrEIoeGnvedquDaKzIb+OUB9qs3SvWJyuk+SRl jpeDbX2aDrN77jqMWfmkC4Av3Cj9Qxmx045TkXmCBPEOOnlrRJcDQVVRl VNGC9A7v1F5l0l3Tc6/kjn0YUiZqJWEPrrgFrvaZ3GNSJvJU7cTiyHbdy DhngGYXmwRWbwraqvb7y0RclEpwWug/2AeINMQ470gnKYd4r4+IW3rZqm azz70UOrxtUpa8e0YgNpMVaGNCSsjJs0HGB4op6GzZ+gfNCx7J4Lqxhz1 DFNkahuivrjbhcu70kWDx5JixUy3oaDZMGUwhRM1nuIB2on+Vm3AB5GBD g==; X-CSE-ConnectionGUID: rFWc5P5MR52g6BI9RK//Ug== X-CSE-MsgGUID: 77TNlHOhRseFakNvDW9jlA== X-IPAS-Result: A0BUAwBQ/PNq/4z/Ja1RCYJZgld1WwEEQ0kDhFSRdAOBE4pShWeMVhSBag8BAQEPRA0EAQGEP0YCjh4CJjUIDgECBAMCAwEBAQEBAQEBAQEBAQoBAQUBAQECAQcFgQ4Thk8NkBIBAgEDGgEIBAsBGAEbEhAcAQIBAgMCERUCAiALHQEFCBAJH4JjAYI6AzcDEblYen8zgQGDKQGBVNhMDYJaAQsUAYEKLoVCgwAgAYUDXRgBRIQ5JxsbgXKBFYEXgVx2gQWBGkICgRkbDgZXgx2CSCIEgiJ6EoFaHmQHAoU0hlsCgU+HJUiBAhwDWSwBVRMNCgsHBYFoAzUSKhVuMh2BIz4XL1gbBgWBHIEegRKBB12BKyhdASGBCIF2AoJRggACAUlBDgdFUwklQxJHJCIIEgkBExosCoEaNTwJJj8VDGcLGgVhCAINDHQ5EDAeJC8FGiY+Gwp+GA1IESw3FRkEPQFuB5JughkvGRQOEQEBBg8XECQaCQoBBx8FBBEBMggEAgEoBgwIFh4fARAODg4FHAIrAgOSVAoCBwIBkB2CIYE1nh5NcQoog3iMJI8+hXwaM4QEgVeSQ5JUC5h9jgqECpFZBgsQA0qEaoFpATqBWXAVO4JnCUoZD1iNUwMLC4NghFDKCyYyAgkDLwEBBwIHAQwBAwuBaIRhix8CJgQDb2ABAQ IronPort-Data: A9a23:j7DtSaK6E+4nLKU0FE+RgZQlxSXFcZb7ZxGr2PjKsXjdYENS1zcOy 2RMWTjVPq3eMzSkft4kbYm+pxsEvpSBm9Y3SFcd+CA2RRqmiyZq6fd1j6vUF3nPRiEWZBs/t 63yUvGZcoZpCCea+Uf1WlTYhSEU/bmSQbbhA/LzNCl0RAt1IA8skhsLd9QR2uaEuvDnRVnS0 T/Oi5eHYgH8gmctajt8B5+r8XuDgtyj4Fv0gXRmDRx7lAe2v2UYCpsZOZawIxPQKqFIHvS3T vr017qw+GXU5X8FUrtJRZ6iLyXm6paLVeS/oiI+t5qK23CulQRuukoPD8fwXG8M49m/c3+d/ /0W3XC4YV9B0qQhA43xWTEAe811FfUuFLMqvRFTvOTLp3AqfUcAzN10In49YLVF39wqW24Q3 91JEhNKKS2M0rfeLLKTEoGAh+w5J8XteYdasXZ6wHSBVrAtQIvIROPB4towMDUY358VW62BI ZBENHw2MEyojx5nYj/7DLoyn+qsj3juehVTqUmeouw85G27IAlZjeSwYIqEIYDSLSlTthnHm kTC30P5OAoXGtWc7ySpzSOugsaayEsXX6pXTtVU7MVCh0WewGEWAhAaWVa35PW0lEO6c9ZeM FAPvC02oK4/8UamQtXwU1u/unHsg/IHc8BbH+t/7ESGzbDZpl7CQGMFVTVGLtchsafaWAAX6 7NApPuxbRQHjVFfYSv1Gmu8xd9qBRUoEA== IronPort-HdrOrdr: A9a23:noBkNqPx1tVLT8BcThmjsMiBIKoaSvp037Dk7S9MoHtuA6qlfq GV7ZMmPHDP6Qr5NEtMpTnEAtjjfZq+z+8Q3WBuB9eftWDd0QPCRr2Kr7GSpgEIcBeRygcy78 tdmtBFeb7NJGk/q9rm6w+lFNtl6tyG/Ke0wdr69R5WPHhXg2UK1XYDNu5deXcGPDV7OQ== X-Talos-CUID: 9a23:A8+AhWooGRs4mUM7FtW+iAHmUdoLTXaA9Cr1GRLiBjhDaeyvahjXxawxxg== X-Talos-MUID: 9a23:/LfdKgq2c5yfhGjxK6cez249COFsw4OuMlsAv5wFndeoCn1yPx7I2Q== X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,207,1787011200"; d="scan'208";a="545889340" Received: from rcdn-l-core-03.cisco.com ([173.37.255.140]) by rcdn-iport-7.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 11 Nov 2026 05:29:43 +0000 Received: from sjc-ads-5675.cisco.com (sjc-ads-5675.cisco.com [10.28.88.189]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by rcdn-l-core-03.cisco.com (Postfix) with ESMTPS id E5FEE180001D1; Wed, 11 Nov 2026 05:29:42 +0000 (GMT) Received: by sjc-ads-5675.cisco.com (Postfix, from userid 1887444) id A37F9CBF202; Tue, 10 Nov 2026 21:29:42 -0800 (PST) From: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" To: openembedded-devel@lists.openembedded.org Cc: Darsh Kelaiya Subject: [oe][meta-python][wrynose][PATCH 2/9] python3-filelock: fix CVE-2026-112935 Date: Tue, 10 Nov 2026 21:29:31 -0800 Message-Id: <20261111052938.3691463-3-dkelaiya@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261111052938.3691463-1-dkelaiya@cisco.com> References: <20261111052938.3691463-1-dkelaiya@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: ANONYMOUS;sjc-ads-5675.cisco.com [10.28.88.189];TLSv1.3;TLS_AES_256_GCM_SHA384;256 X-Outbound-SMTP-Client: 10.28.88.189, sjc-ads-5675.cisco.com X-Outbound-Node: rcdn-l-core-03.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Wed, 11 Nov 2026 05:29:51 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/132121 From: Darsh Kelaiya Backport atomic stale-marker takeover [1] and its recovery-PID [2] and raced-restoration [3] corrections, following advisory [4]. Pin observed inodes and exchange markers atomically so a stale observation cannot remove a successor's live lock. Route lifetime expiry through the same takeover path and retain the 3.25.2 public API and records. Add ctypes for the native atomic-exchange helpers and retain the applicable upstream recovery regressions. [1] https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b [2] https://github.com/tox-dev/filelock/commit/5bfde2890ff907a5318c1b7f815fcd52a887f127 [3] https://github.com/tox-dev/filelock/commit/77eb01a21ce842659e7beb9fd50a3dc935647416 [4] https://github.com/tox-dev/filelock/security/advisories/GHSA-fq56-3cx3-fjxp Signed-off-by: Darsh Kelaiya --- .../python3-filelock/CVE-2026-112935_p1.patch | 945 ++++++++++++++++++ .../python3-filelock/CVE-2026-112935_p2.patch | 90 ++ .../python3-filelock/CVE-2026-112935_p3.patch | 425 ++++++++ .../python/python3-filelock_3.25.2.bb | 4 + 4 files changed, 1464 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p1.patch create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p2.patch create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p3.patch diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p1.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p1.patch new file mode 100644 index 0000000000..d7a4bd5944 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p1.patch @@ -0,0 +1,945 @@ +From 740ff666b62a88496c8d7394e4e6230605e4d62b Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 02:53:44 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(soft):=20break=20stale=20mar?= + =?UTF-8?q?kers=20without=20emptying=20the=20lock=20path=20(#843)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-112935 +Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b] + +Backport Changes: +- Port inode pinning, atomic exchanges and stranded-marker recovery + to SoftFileLock; retain two-line PID/hostname owner records. +- Use existing lock_file/context fields in place of 4.x descriptor + state, paths and identity helpers; keep the existing public API. +- Override lifetime expiry through the secure takeover path and + adapt the existing tests for secure marker reads and write rollback. +- Inject expiry-race failures through the atomic-exchange helpers + in the existing regression instead of the former Path.rename path. +- Omit new marker/lease/strict-lock APIs and their version-specific + tests and docs; retain focused SoftFileLock regressions. + +Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> +Signed-off-by: Darsh Kelaiya +--- + src/filelock/_soft.py | 362 ++++++++++++++++++++++++++++++++++++++------ + src/filelock/_util.py | 267 +++++++++++++++++++++++++++++++- + tests/test_lock_expiry.py | 3 +- + tests/test_soft_security.py | 129 ++++++++++++++++ + tests/test_soft_stale.py | 13 +- + 5 files changed, 719 insertions(+), 55 deletions(-) + create mode 100644 tests/test_soft_security.py + +diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py +index 3a535ea..852ac71 100644 +--- a/src/filelock/_soft.py ++++ b/src/filelock/_soft.py +@@ -1,9 +1,12 @@ + from __future__ import annotations + + import os ++import re ++import secrets + import socket + import stat + import sys ++import threading + import time + from contextlib import suppress + from errno import EACCES, EEXIST, EPERM, ESRCH +@@ -11,68 +14,240 @@ from pathlib import Path + from typing import Final + + from ._api import BaseFileLock +-from ._util import ensure_directory_exists, raise_on_not_writable_file +- +-#: Contenders act on the record in the marker, so no other account may write it, whatever the mode or umask. +-_OTHERS_WRITE: Final[int] = stat.S_IWGRP | stat.S_IWOTH ++from ._util import ( ++ break_lock_file, ++ ensure_directory_exists, ++ exchange_names, ++ exchange_supported, ++ pin_inode, ++ raise_on_not_writable_file, ++ remove_own_file, ++ write_all, ++) + + _WIN_SYNCHRONIZE = 0x100000 + _WIN_ERROR_INVALID_PARAMETER = 87 ++_MAX_LOCK_FILE_SIZE: Final[int] = 1024 ++#: Contenders act on the record in the marker, so no other account may write it, whatever the mode or umask. ++_OTHERS_WRITE: Final[int] = stat.S_IWGRP | stat.S_IWOTH ++_CLAIMS_IN_PROGRESS: Final[set[str]] = set() ++_CLAIMS_LOCK: Final[threading.Lock] = threading.Lock() ++_RELEASED_RECORD: Final[str] = "filelock/released\n" + + + class SoftFileLock(BaseFileLock): + """ + Portable file lock based on file existence. + +- Unlike :class:`UnixFileLock ` and :class:`WindowsFileLock `, this +- lock does not use OS-level locking primitives. Instead, it creates the lock file with ``O_CREAT | O_EXCL`` and +- treats its existence as the lock indicator. This makes it work on any filesystem but leaves stale lock files behind +- if the process crashes without releasing the lock. +- +- To mitigate stale locks, the lock file contains the PID and hostname of the holding process. On contention, if the +- holder is on the same host and its PID no longer exists, the stale lock is broken automatically. ++ The two-line PID/hostname record remains compatible with filelock 3.25. ++ A stale marker is pinned and exchanged atomically so reclaiming it cannot ++ remove a successor's live lock. Filesystems without an atomic exchange ++ keep the marker rather than leave the lock path temporarily empty. + + """ + + def _acquire(self) -> None: + raise_on_not_writable_file(self.lock_file) + ensure_directory_exists(self.lock_file) +- flags = ( +- os.O_WRONLY # open for writing only +- | os.O_CREAT +- | os.O_EXCL # together with above raise EEXIST if the file specified by filename exists +- | os.O_TRUNC # truncate the file to zero byte +- ) ++ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC + if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: + flags |= o_nofollow + try: +- file_handler = os.open(self.lock_file, flags, self._open_mode() & ~_OTHERS_WRITE) ++ fd = os.open(self.lock_file, flags, self._open_mode() & ~_OTHERS_WRITE) + except OSError as exception: +- if not ( +- exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32") +- ): # pragma: win32 no cover ++ if not (exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32")): + raise +- if exception.errno == EEXIST and sys.platform != "win32": # pragma: win32 no cover ++ if exception.errno == EEXIST and sys.platform != "win32": + self._try_break_stale_lock() + else: +- self._write_lock_info(file_handler) +- self._context.lock_file_fd = file_handler ++ # Publish held state only once the record is on disk. On failure, ++ # close the descriptor and remove only the marker it created. ++ try: ++ self._write_lock_info(fd) ++ except BaseException: ++ try: ++ with suppress(OSError): ++ remove_own_file(self.lock_file, fd) ++ finally: ++ os.close(fd) ++ raise ++ self._context.lock_file_fd = fd + + def _try_break_stale_lock(self) -> None: +- with suppress(OSError, ValueError): +- content = Path(self.lock_file).read_text(encoding="utf-8") +- lines = content.strip().splitlines() +- if len(lines) != 2: # noqa: PLR2004 ++ with suppress(OSError, ValueError), pin_inode(self.lock_file) as pinned: ++ content, marker = _read_lock_file(self.lock_file) ++ if pinned not in {None, marker.st_ino}: + return +- pid_str, hostname = lines +- if hostname != socket.gethostname(): ++ if content == _RELEASED_RECORD: ++ self._take_over(marker, None) + return +- pid = int(pid_str) +- if self._is_process_alive(pid): ++ holder = _parse_lock_holder(content) ++ if holder is None: ++ # 3.25 does not age out malformed or half-written markers. + return +- break_path = f"{self.lock_file}.break.{os.getpid()}" +- Path(self.lock_file).rename(break_path) +- Path(break_path).unlink() ++ pid, hostname = holder ++ if hostname == socket.gethostname() and not self._is_process_alive(pid): ++ self._take_over(marker, pid) ++ ++ def _try_break_expired_lock(self) -> None: ++ if (lifetime := self._context.lifetime) is None: ++ return ++ with suppress(OSError), pin_inode(self.lock_file) as pinned: ++ content, marker = _read_lock_file(self.lock_file) ++ if pinned not in {None, marker.st_ino} or time.time() - marker.st_mtime < lifetime: ++ return ++ holder = _parse_lock_holder(content) ++ self._take_over(marker, None if holder is None else holder[0]) ++ ++ def _take_over(self, marker: os.stat_result, breaker_pid: int | None) -> None: ++ # Swap the judged marker for a complete marker of ours in one exchange, so the lock path never stands empty. ++ # Once the file swapped out proves to be the one judged, this lock holds the path; the caller's pin keeps that ++ # file's inode number from naming anything else. Only a peer's exchange can move our marker after that, and ++ # the peer puts it back because what it took is not the file it judged. Every failure before the handoff takes ++ # our marker off the path and puts back what the exchange took. Without an exchange, the portable break decides. ++ if exchange_supported(): # pragma: win32 no cover ++ self._take_over_by_exchange(marker, breaker_pid) ++ else: ++ break_lock_file(self.lock_file, marker.st_mtime, marker.st_ino) ++ ++ ++ def _take_over_by_exchange(self, marker: os.stat_result, breaker_pid: int | None) -> None: # pragma: win32 no cover ++ if (current := os.lstat(self.lock_file)).st_mtime != marker.st_mtime or current.st_ino != marker.st_ino: ++ return ++ if breaker_pid is not None and self._restore_stranded_marker(breaker_pid, marker): ++ return ++ aside: Final = f"{self.lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}" ++ flags: Final = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) ++ fd: Final = os.open(aside, flags, self._open_mode() & ~_OTHERS_WRITE) ++ exchanged = held = False ++ try: ++ self._write_lock_info(fd) ++ if not exchange_names(aside, self.lock_file): ++ return # NFS, SMB and the like refuse it; renaming the marker aside would empty the path ++ exchanged = True ++ if (taken := os.lstat(aside)).st_mtime != marker.st_mtime or taken.st_ino != marker.st_ino: ++ return # a peer recreated the marker, which the cleanup below swaps back ++ Path(aside).unlink() ++ exchanged = False ++ self._context.lock_file_fd = fd ++ held = True ++ finally: ++ if not held: ++ self._abandon_take_over(fd, aside, put_back=exchanged) ++ ++ ++ def _restore_stranded_marker(self, breaker_pid: int, marker: os.stat_result) -> bool: # pragma: win32 no cover ++ # A breaker killed between its exchange and the put-back leaves its own marker at the path and the live ++ # holder's marker under its break name. A recoverer first renames that record to a name of its own. Only one ++ # contender wins the rename, so two of them never exchange the same record and reverse each other. A record ++ # a dead recoverer holds is claimable again. A claim of this process that no thread is working on was ++ # abandoned by a failed put-back, and the next poll takes it up again. Where the marker cannot be put back ++ # the lock counts as held, as it does wherever no exchange is possible. ++ if (candidates := self._stranded_candidates(breaker_pid)) is None: ++ return True ++ return any(self._recover_claimed(path, breaker_pid, marker, adopt=abandoned) for path, abandoned in candidates) ++ ++ ++ def _stranded_candidates(self, breaker_pid: int) -> list[tuple[str, bool]] | None: # pragma: win32 no cover ++ directory, name = os.path.split(self.lock_file) ++ stranded: Final = re.compile(rf"{re.escape(name)}\.break\.{breaker_pid}\.[0-9a-f]{{32}}") ++ claimed: Final = re.compile(rf"{re.escape(name)}\.restore\.(\d+)\.[0-9a-f]{{32}}") ++ candidates: list[tuple[str, bool]] = [] ++ with os.scandir(directory or os.curdir) as entries: ++ for entry in entries: ++ if not entry.is_file(follow_symlinks=False): ++ continue ++ path = str(Path(directory) / entry.name) ++ if stranded.fullmatch(entry.name): ++ candidates.append((path, False)) ++ elif found := claimed.fullmatch(entry.name): ++ ours = int(found[1]) == os.getpid() ++ if not (ours or not self._is_process_alive(int(found[1]))): ++ return None ++ candidates.append((path, ours)) ++ return sorted(candidates) ++ ++ ++ def _recover_claimed( ++ self, path: str, breaker_pid: int, marker: os.stat_result, *, adopt: bool ++ ) -> bool: # pragma: win32 no cover ++ # One try/finally owns the registration from the first step to the last, so no interruption can leave it set. ++ # Registered before the rename publishes the name, so a thread that scans meanwhile sees a claim in progress. ++ own: Final = path if adopt else f"{self.lock_file}.restore.{os.getpid()}.{secrets.token_hex(16)}" ++ if not _begin_claim(own): ++ return True ++ try: ++ if not adopt: ++ try: ++ Path(path).rename(own) ++ except OSError: # a peer that won the rename now holds the record, so the lock counts as held ++ return True ++ return self._restore_claimed(own, breaker_pid, marker) ++ finally: ++ _end_claim(own) ++ ++ ++ def _restore_claimed(self, own: str, breaker_pid: int, marker: os.stat_result) -> bool: # pragma: win32 no cover ++ try: ++ content, _ = _read_lock_file(own) ++ except OSError: ++ self._return_claim(own, breaker_pid) ++ return True ++ if not self._marker_is_live(content, breaker_pid): ++ with suppress(OSError): ++ Path(own).unlink() ++ return False ++ # The inode of the marker judged dead must still be at the path, or a peer has acted since. ++ with suppress(OSError): ++ current = os.lstat(self.lock_file) ++ if (current.st_ino, current.st_mtime) == (marker.st_ino, marker.st_mtime) and exchange_names( ++ own, self.lock_file ++ ): ++ if os.lstat(own).st_ino == marker.st_ino: ++ Path(own).unlink() ++ return True ++ # A peer replaced the path after the check, so the exchange took its marker: swap it straight back. ++ exchange_names(own, self.lock_file) ++ self._return_claim(own, breaker_pid) ++ return True ++ ++ ++ def _return_claim(self, own: str, breaker_pid: int) -> None: # pragma: win32 no cover ++ # A refused rename leaves the claim under this process's PID, and the next poll retries it. ++ with suppress(OSError): ++ Path(own).rename(f"{self.lock_file}.break.{breaker_pid}.{secrets.token_hex(16)}") ++ ++ ++ def _abandon_take_over(self, fd: int, aside: str, *, put_back: bool) -> None: # pragma: win32 no cover ++ self._context.lock_file_fd = None ++ try: ++ if put_back: ++ exchange_names(aside, self.lock_file) ++ finally: ++ try: ++ # Whichever name still holds our marker loses it: the descriptor keeps its inode number ours. ++ own: Final = os.fstat(fd) ++ for name in (self.lock_file, aside): ++ with suppress(FileNotFoundError): ++ if os.path.samestat(os.lstat(name), own): ++ Path(name).unlink() ++ finally: ++ os.close(fd) ++ ++ ++ @classmethod ++ def _marker_is_live(cls, content: str | None, breaker_pid: int) -> bool: ++ if content == _RELEASED_RECORD: ++ return False ++ holder = _parse_lock_holder(content) ++ if holder is None: ++ # Empty, malformed and newer protocols remain held as in 3.25. ++ return True ++ pid, hostname = holder ++ if pid == breaker_pid and hostname == socket.gethostname(): ++ return False ++ return hostname != socket.gethostname() or cls._is_process_alive(pid) ++ + + @staticmethod + def _is_process_alive(pid: int) -> bool: +@@ -95,27 +270,58 @@ class SoftFileLock(BaseFileLock): + raise + return True + ++ + @staticmethod + def _write_lock_info(fd: int) -> None: +- with suppress(OSError): +- os.write(fd, f"{os.getpid()}\n{socket.gethostname()}\n".encode()) ++ write_all(fd, f"{os.getpid()}\n{socket.gethostname()}\n".encode()) + + def _release(self) -> None: +- assert self._context.lock_file_fd is not None # noqa: S101 +- os.close(self._context.lock_file_fd) +- self._context.lock_file_fd = None ++ fd = self._context.lock_file_fd ++ assert fd is not None # noqa: S101 ++ identity: tuple[int, int] | None = None ++ with suppress(OSError): ++ identity = _file_identity(os.fstat(fd)) + if sys.platform == "win32": +- self._windows_unlink_with_retry() +- else: +- with suppress(OSError): ++ self._context.lock_file_fd = None ++ try: ++ os.close(fd) ++ finally: ++ if identity is not None: ++ self._windows_unlink_with_retry(identity) ++ return ++ unlinked = self._unlink_open_marker(fd, identity) ++ self._context.lock_file_fd = None ++ try: ++ os.close(fd) ++ finally: ++ if not unlinked and identity is not None: ++ with suppress(OSError): ++ if _file_identity(os.lstat(self.lock_file)) == identity: ++ Path(self.lock_file).unlink() ++ ++ def _unlink_open_marker(self, fd: int, identity: tuple[int, int] | None) -> bool: ++ if identity is None: ++ return True ++ try: ++ if _file_identity(os.lstat(self.lock_file)) == identity: + Path(self.lock_file).unlink() ++ return True ++ _leave_released_record(fd) ++ except FileNotFoundError: ++ return True ++ except OSError: ++ return False ++ return True ++ + +- def _windows_unlink_with_retry(self) -> None: ++ def _windows_unlink_with_retry(self, identity: tuple[int, int] | None = None) -> None: + max_retries = 10 + retry_delay = 0.001 + for attempt in range(max_retries): + # Windows doesn't immediately release file handles after close, causing EACCES/EPERM on unlink + try: ++ if identity is not None and _file_identity(os.lstat(self.lock_file)) != identity: ++ return + Path(self.lock_file).unlink() + except OSError as exc: # noqa: PERF203 + if exc.errno not in {EACCES, EPERM}: +@@ -127,6 +333,68 @@ class SoftFileLock(BaseFileLock): + return + + +-__all__ = [ +- "SoftFileLock", +-] ++def _file_identity(st: os.stat_result) -> tuple[int, int]: ++ # (st_dev, st_ino) names the concrete inode behind a path, so a marker recreated at the same pathname after an ++ # expired lease reads as a different file. CPython populates both on Windows from the volume serial and file index. ++ return st.st_dev, st.st_ino ++ ++ ++def _begin_claim(name: str | Path) -> bool: # pragma: win32 no cover ++ # Keyed by the file name alone: its random token identifies the claim, and the spelling of the directory varies. ++ with _CLAIMS_LOCK: ++ if (key := Path(name).name) in _CLAIMS_IN_PROGRESS: ++ return False ++ _CLAIMS_IN_PROGRESS.add(key) ++ return True ++ ++ ++def _end_claim(name: str | Path) -> None: # pragma: win32 no cover ++ with _CLAIMS_LOCK: ++ _CLAIMS_IN_PROGRESS.discard(Path(name).name) ++ ++ ++def _leave_released_record(fd: int) -> None: ++ # A peer's exchange holds our marker under its break name and will put it back. Overwriting the record lets ++ # contenders age it out instead of waiting on this live PID. The write comes before the truncate so the file is ++ # never empty. ++ if sys.platform != "win32": # pragma: win32 no cover ++ os.pwrite(fd, released := b"filelock/released\n", 0) ++ os.ftruncate(fd, len(released)) ++ ++ ++def _read_lock_file(path: str) -> tuple[str | None, os.stat_result]: ++ # 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 stat result still flows 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): # pragma: needs fifo ++ return None, st ++ # 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 = 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 ++ data = os.read(fd, _MAX_LOCK_FILE_SIZE + 1) ++ finally: ++ os.close(fd) ++ if len(data) <= _MAX_LOCK_FILE_SIZE: ++ with suppress(UnicodeDecodeError): ++ return data.decode("utf-8"), st ++ return None, st ++ ++ ++def _parse_lock_holder(content: str | None) -> tuple[int, str] | None: ++ if content is None or len(lines := content.strip().splitlines()) != 2: ++ return None ++ pid_text, hostname = lines ++ if not (pid_text.isascii() and pid_text.isdigit()): ++ return None ++ pid = int(pid_text) ++ return (pid, hostname) if 1 <= pid <= 2**31 - 1 else None ++ ++ ++__all__ = ["SoftFileLock"] +diff --git a/src/filelock/_util.py b/src/filelock/_util.py +index 6701523..98d4f6f 100644 +--- a/src/filelock/_util.py ++++ b/src/filelock/_util.py +@@ -1,10 +1,20 @@ + from __future__ import annotations + ++import errno + import os ++import platform ++import secrets + import stat + import sys +-from errno import EACCES, EISDIR ++from contextlib import contextmanager, suppress ++from errno import EACCES, EINVAL, EIO, EISDIR, ENOSYS, EXDEV ++from functools import cache + from pathlib import Path ++from typing import TYPE_CHECKING, Final, cast ++ ++if TYPE_CHECKING: ++ import ctypes ++ from collections.abc import Callable, Generator + + + def raise_on_not_writable_file(filename: str) -> None: +@@ -47,7 +57,262 @@ def ensure_directory_exists(filename: Path | str) -> None: + Path(filename).parent.mkdir(parents=True, exist_ok=True) + + ++def write_all(fd: int, data: bytes) -> None: ++ """ ++ Write the whole buffer to *fd*, looping over the short writes POSIX lets ``os.write`` make. ++ ++ A caller that stopped after one short write would leave a truncated marker behind. A peer that reads between two ++ writes sees a prefix of the record; each caller has to tolerate that. Lock records need not survive a power loss, so ++ we skip ``fsync``. ++ ++ :param fd: file descriptor open for writing. ++ :param data: bytes to write in full. ++ ++ :raises OSError: if a write reports zero progress before the record is complete. ++ ++ """ ++ remaining = memoryview(data) ++ while remaining: ++ if (written := os.write(fd, remaining)) == 0: ++ raise OSError(EIO, "os.write wrote 0 bytes before the record was complete") ++ remaining = remaining[written:] ++ ++ ++@contextmanager ++def pin_inode(lock_file: str) -> Generator[int | None, None, None]: ++ """ ++ Keep the file at *lock_file* allocated for the length of the block and yield its inode number. ++ ++ An inode is the kernel's identity for a file, independent of its name, and its number names a file only while the ++ inode stays allocated. ext4 hands a freed number to the next file created. Once a stale marker is unlinked, the next ++ holder's marker can therefore carry the number a contender read a moment ago. A caller that reads the marker inside ++ the block, gives up unless it read the yielded inode, and breaks the marker before leaving can rely on the number. ++ ++ :param lock_file: path to the lock file about to be read and perhaps broken. ++ ++ :returns: the pinned inode number, or ``None`` where nothing can be pinned. Windows cannot rename a file this ++ process holds open, so it needs no pin. Without ``O_PATH`` a symlink or a socket cannot be opened. ++ ++ :raises FileNotFoundError: if the path is gone. ++ ++ """ ++ if sys.platform == "win32": # pragma: win32 cover ++ yield None ++ else: # pragma: win32 no cover ++ # O_PATH pins any node, a symlink or socket included, without read permission and without the filesystem seeing ++ # an open; elsewhere a nonblocking read-only open pins what it can. ++ flags: Final = getattr(os, "O_PATH", os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)) | getattr(os, "O_NOFOLLOW", 0) ++ try: ++ fd = os.open(lock_file, flags) ++ except FileNotFoundError: ++ raise ++ except OSError: ++ yield None ++ return ++ try: ++ yield os.fstat(fd).st_ino ++ finally: ++ os.close(fd) ++ ++ ++def remove_own_file(lock_file: str, fd: int) -> None: # pragma: win32 no cover ++ """ ++ Remove *lock_file* while it still names the file open as *fd*; the caller closes the descriptor afterwards. ++ ++ Where the platform has an atomic exchange, a stale break takes the file through it, so a peer's marker found at the ++ path goes back. Otherwise the path is unlinked by name if it still names this file. The open descriptor keeps the ++ inode allocated, so a matching number cannot belong to a successor's marker. ++ ++ :raises OSError: if the descriptor cannot be read or the unlink fails. ++ ++ """ ++ own: Final = os.fstat(fd) ++ with suppress(FileNotFoundError): ++ break_lock_file(lock_file, own.st_mtime, own.st_ino) ++ if os.path.samestat(os.lstat(lock_file), own): ++ Path(lock_file).unlink() ++ ++ ++def break_lock_file(lock_file: str, mtime_before: float, ino_before: int) -> None: ++ """ ++ Remove the lock file a caller found stale with modification time *mtime_before* and inode *ino_before*. ++ ++ The caller keeps the judged file open until we return, through :func:`pin_inode` or its own descriptor, so no ++ other file can carry *ino_before* meanwhile. Right before breaking we ``lstat`` the lock again and leave it alone ++ if a peer recreated it since the caller judged it stale, as Mercurial re-reads a lock under its break lock. ++ ++ On Linux (``renameat2``) and macOS (``renamex_np``) the platform can exchange two names in one step. There we swap ++ the marker with a placeholder of our own, so the lock path never stands empty, and a contender that polls meanwhile ++ finds a file it cannot parse and waits. If the file we took is not the one judged stale, a peer recreated the lock, ++ and a second exchange puts it back. Otherwise we remove it and then our placeholder. Of several processes racing to ++ break the same lock, only one takes the stale file. ++ ++ Windows cannot exchange names, but it refuses to rename a marker its live holder keeps open. There we rename the ++ marker aside and, if it was recreated after all, link it back. ``link`` never replaces a name, so a marker a third ++ process created meanwhile keeps the path. Elsewhere without an exchange (NFS, SMB and other filesystems that refuse ++ it, platforms without the call) a rename would leave the path empty beside a possibly live holder. We then do not ++ move the marker, and the caller waits for ``timeout`` or ``break_lock()``. ++ ++ We compare inodes as well as modification times, because NFS and FAT store coarse modification times. The times are ++ compared for equality, since a file's owner can set any time. We ``lstat`` so a symlink a peer swaps in is never ++ followed. The break name carries a random token so no other process can guess it. ++ ++ :param lock_file: path to the lock file to break. ++ :param mtime_before: modification time the caller saw when it judged the lock stale. ++ :param ino_before: inode number the caller saw when it judged the lock stale. ++ ++ :raises OSError: if taking the marker fails, for example because the file vanished, another user owns it in a ++ sticky directory, or on Windows its holder keeps it open. ++ ++ """ ++ if (current := os.lstat(lock_file)).st_mtime != mtime_before or current.st_ino != ino_before: ++ return ++ break_path: Final[str] = f"{lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}" ++ if exchange_supported(): # pragma: win32 no cover ++ _swap_out(lock_file, break_path, mtime_before, ino_before) ++ elif _MOVES_WITHOUT_EXCHANGE: # pragma: win32 cover ++ _break_by_rename(lock_file, break_path, mtime_before, ino_before) ++ ++ ++def _swap_out(lock_file: str, break_path: str, mtime_before: float, ino_before: int) -> None: # pragma: win32 no cover ++ # An atomic exchange with a placeholder takes the marker without the lock path ever being empty. No contender can ++ # then create a marker while this one is judged, and a live marker goes back the same way. ++ fd: Final = os.open(break_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600) ++ try: ++ try: ++ # Not a record any lock writes, so a contender that reads it waits, and one left by a crash ages out. The ++ # PID tells the contender that evicts it whose break names to search for a marker this process left. ++ write_all(fd, f"filelock/break\n{os.getpid()}\n".encode()) ++ exchanged = exchange_names(break_path, lock_file) ++ except OSError: ++ exchanged = False ++ if not exchanged: ++ Path(break_path).unlink() ++ return ++ if (grabbed := os.lstat(break_path)).st_mtime == mtime_before and grabbed.st_ino == ino_before: ++ Path(break_path).unlink() ++ # Our descriptor keeps the placeholder's inode allocated, so a matching number at the path is still ours. ++ if os.path.samestat(os.lstat(lock_file), os.fstat(fd)): ++ Path(lock_file).unlink() ++ else: ++ exchange_names(break_path, lock_file) ++ Path(break_path).unlink() ++ finally: ++ os.close(fd) ++ ++ ++def _break_by_rename( ++ lock_file: str, break_path: str, mtime_before: float, ino_before: int ++) -> None: # pragma: win32 cover ++ # Renaming the marker aside empties the path until a recreated live marker is put back. link never replaces a name, ++ # so a marker a third process created meanwhile keeps the path, and the live one waits under the break name. ++ # Without os.link, or where the filesystem refuses hard links, the live one waits there too. ++ Path(lock_file).rename(break_path) ++ if (st_after := os.lstat(break_path)).st_mtime == mtime_before and st_after.st_ino == ino_before: ++ Path(break_path).unlink() ++ elif stat.S_ISREG(st_after.st_mode) and hasattr(os, "link"): # pragma: needs hard-link ++ with suppress(OSError): ++ os.link(break_path, lock_file, follow_symlinks=False) ++ Path(break_path).unlink() ++ ++ ++def exchange_supported() -> bool: ++ """Whether this platform has a call that swaps two names in one step; a filesystem may still refuse it.""" ++ return _rename_call() is not None ++ ++ ++def exchange_names(source: str, target: str) -> bool: # pragma: win32 no cover ++ """ ++ Swap the names *source* and *target* in one step, so neither ever stands empty, where :func:`exchange_supported`. ++ ++ :returns: ``False`` where the filesystem cannot, so the caller falls back ++ ++ :raises OSError: for any other failure, such as a missing name or a denied rename ++ ++ """ ++ renamer, exchange_flag = cast("tuple[Callable[[bytes, bytes, int], int], int]", _rename_call()) ++ if not (error := renamer(os.fsencode(source), os.fsencode(target), exchange_flag)): ++ return True ++ # EXDEV: overlayfs and some FUSE filesystems refuse an exchange between layers. ++ if error in {EINVAL, ENOSYS, EXDEV, getattr(errno, "ENOTSUP", EINVAL), getattr(errno, "EOPNOTSUPP", EINVAL)}: ++ return False ++ raise OSError(error, os.strerror(error), source, None, target) ++ ++ ++@cache ++def _rename_call() -> tuple[Callable[[bytes, bytes, int], int], int] | None: ++ # The call returns 0 or the errno. Looked up on first use, since most processes never break a lock and ctypes is ++ # slow to import. None where ctypes is missing (GraalPy) or the platform has no exchange. ++ try: ++ import ctypes # ruff:ignore[import-outside-top-level] # only a stale break needs it ++ except ImportError: # pragma: no cover # GraalPy ships without ctypes ++ return None ++ if sys.platform in {"linux", "android"}: # pragma: linux cover ++ if (renameat2 := _renameat2(ctypes.CDLL(None, use_errno=True))) is None: ++ return None # pragma: no cover # an architecture the CI matrix does not run ++ return renameat2, 2 # RENAME_EXCHANGE in linux/fs.h ++ if sys.platform == "darwin": # pragma: darwin cover ++ renamex_np: Final = ctypes.CDLL(None, use_errno=True).renamex_np ++ renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] ++ renamex_np.restype = ctypes.c_int ++ ++ def call_renamex_np(source: bytes, target: bytes, flags: int) -> int: ++ return 0 if renamex_np(source, target, flags) == 0 else ctypes.get_errno() ++ ++ return call_renamex_np, 0x2 # RENAME_SWAP in sys/stdio.h ++ return None # pragma: linux no cover # pragma: android no cover # pragma: darwin no cover ++ ++ ++def _renameat2(libc: ctypes.CDLL) -> Callable[[bytes, bytes, int], int] | None: # pragma: linux cover ++ # Reached through syscall(2) rather than the C library's wrapper. glibc 2.28, musl 1.2.2 and bionic API 30 added ++ # the wrapper, and Termux ships an older bionic; the kernel has had the call since 3.15. The number is the ++ # architecture's own, so an architecture outside the table gets no exchange rather than another call. ++ import ctypes # ruff:ignore[import-outside-top-level] # only a stale break needs it ++ ++ numbers: Final = { ++ "x86_64": 316, ++ "i386": 353, ++ "i686": 353, ++ "armv7l": 382, ++ "armv8l": 382, ++ "aarch64": 276, ++ "riscv64": 276, ++ "loongarch64": 276, ++ "ppc64le": 357, ++ "s390x": 347, ++ } ++ if (number := numbers.get(platform.machine())) is None: ++ return None # pragma: no cover # an architecture the CI matrix does not run ++ syscall: Final = libc.syscall ++ syscall.restype = ctypes.c_long ++ at_fdcwd: Final = -100 ++ ++ def call_renameat2(source: bytes, target: bytes, flags: int) -> int: ++ arguments: Final = ( ++ ctypes.c_long(number), ++ ctypes.c_int(at_fdcwd), ++ ctypes.c_char_p(source), ++ ctypes.c_int(at_fdcwd), ++ ctypes.c_char_p(target), ++ ctypes.c_uint(flags), ++ ) ++ return 0 if syscall(*arguments) == 0 else ctypes.get_errno() ++ ++ return call_renameat2 ++ ++ ++# Windows refuses to rename a marker its live holder keeps open, so renaming aside only moves a marker nobody holds. ++# Elsewhere without an exchange a contender could take the emptied path beside a live holder, so the marker stays and ++# the lock counts as held. ++_MOVES_WITHOUT_EXCHANGE: Final[bool] = sys.platform == "win32" ++ + __all__ = [ ++ "break_lock_file", ++ "exchange_names", ++ "exchange_supported", ++ "pin_inode", ++ "remove_own_file", ++ "write_all", + "ensure_directory_exists", + "raise_on_not_writable_file", + ] +diff --git a/tests/test_lock_expiry.py b/tests/test_lock_expiry.py +index c4481ca..14042a7 100644 +--- a/tests/test_lock_expiry.py ++++ b/tests/test_lock_expiry.py +@@ -49,7 +49,8 @@ def test_expired_lock_race_rename_fails(tmp_path: Path, mocker: MockerFixture) - + lock_path.touch() + os.utime(lock_path, (0, 0)) + +- mocker.patch("filelock._api.pathlib.Path.rename", side_effect=FileNotFoundError) ++ mocker.patch("filelock._soft.exchange_supported", return_value=True) ++ mocker.patch("filelock._soft.exchange_names", side_effect=FileNotFoundError) + + lock = SoftFileLock(lock_path, lifetime=0.1, timeout=0.5) + with pytest.raises(TimeoutError): +diff --git a/tests/test_soft_security.py b/tests/test_soft_security.py +new file mode 100644 +index 0000000..2a49e1f +--- /dev/null ++++ b/tests/test_soft_security.py +@@ -0,0 +1,129 @@ ++from __future__ import annotations ++ ++import os ++import socket ++import sys ++import time ++from pathlib import Path ++from typing import TYPE_CHECKING, Final ++ ++import pytest ++ ++from filelock import SoftFileLock, Timeout ++from filelock._util import exchange_names, exchange_supported ++ ++if TYPE_CHECKING: ++ from collections.abc import Callable ++ from pytest_mock import MockerFixture ++ ++pytestmark = pytest.mark.skipif(sys.platform == "win32" or not exchange_supported(), reason="needs atomic exchange") ++_DEAD_PID = 2**22 + 1 ++_HOST = socket.gethostname() ++ ++@pytest.fixture ++def lock_path(tmp_path: Path) -> Path: ++ return tmp_path / "test.lock" ++ ++@pytest.fixture ++def lock_type() -> type[SoftFileLock]: ++ return SoftFileLock ++ ++@pytest.fixture ++def record() -> str: ++ return _holder(_DEAD_PID) ++ ++@pytest.fixture ++def on_break_recheck(mocker: MockerFixture) -> Callable[[Path, Callable[[], None]], None]: ++ """Schedule an action for right after the stale break re-reads the lock file and before it takes the marker.""" ++ ++ def schedule(lock: Path, action: Callable[[], None]) -> None: ++ alive: Final = SoftFileLock._is_process_alive ++ lstat: Final = os.lstat ++ state: list[str] = [] ++ ++ def judged(pid: int) -> bool: ++ # The owner check comes right before the break, whose first look at the path is the recheck. ++ state.append("judged") ++ return alive(pid) ++ ++ def lstat_then_act(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: ++ found: Final = lstat(path, dir_fd=dir_fd) ++ if state == ["judged"] and os.fspath(path) == os.fspath(lock): ++ state.append("acted") ++ action() ++ return found ++ ++ mocker.patch.object(SoftFileLock, "_is_process_alive", side_effect=judged) ++ mocker.patch.object(os, "lstat", new=lstat_then_act) ++ ++ return schedule ++ ++ ++def _holder(pid: int, *, host: str = _HOST, start: int | None = None) -> str: ++ return "\n".join([str(pid), host, *([] if start is None else [str(start)])]) + "\n" ++ ++ ++def test_stale_break_racing_a_successor_admits_no_third_holder( ++ lock_path: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None] ++) -> None: ++ lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") ++ successor: Final[SoftFileLock] = SoftFileLock(lock_path, timeout=0) ++ ++ def successor_breaks_and_acquires_first() -> None: ++ lock_path.unlink() ++ successor.acquire() ++ ++ on_break_recheck(lock_path, successor_breaks_and_acquires_first) ++ _assert_times_out(lock_path) ++ mocker.stopall() ++ ++ _assert_times_out(lock_path) ++ successor.release() ++ ++ ++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") ++def test_stale_break_keeps_the_lock_when_a_peer_swaps_its_marker_aside( # pragma: win32 no cover ++ lock_path: Path, mocker: MockerFixture ++) -> None: ++ lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") ++ (peer := lock_path.with_name("peer")).write_text(_holder(os.getpid()), encoding="utf-8") ++ unlink: Final = Path.unlink ++ ++ def peer_exchanges_then_unlink(path: Path, *, missing_ok: bool = False) -> None: ++ exchange_names(str(lock_path), str(peer)) ++ unlink(path, missing_ok=missing_ok) ++ ++ mocker.patch.object(Path, "unlink", new=peer_exchanges_then_unlink) ++ lock: Final = SoftFileLock(lock_path) ++ lock.acquire(blocking=False) ++ mocker.stopall() ++ exchange_names(str(lock_path), str(peer)) ++ ++ assert (lock.is_locked, lock_path.read_text(encoding="utf-8")) == (True, _holder(os.getpid())) ++ lock.release() ++ assert list(lock_path.parent.glob("test.lock*")) == [] ++ ++ ++def _break_name(lock: Path, pid: int = _DEAD_PID) -> Path: # pragma: win32 no cover ++ return lock.with_name(f"test.lock.break.{pid}.{'0' * 32}") ++ ++ ++def _live(record: str) -> str: # pragma: win32 no cover ++ return record.replace(str(_DEAD_PID), str(os.getpid())) ++ ++ ++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") ++def test_stale_break_restores_a_live_marker_a_dead_breaker_left_under_its_break_name( # pragma: win32 no cover ++ tmp_path: Path, lock_type: type[SoftFileLock], record: str ++) -> None: ++ (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") ++ _break_name(lock).write_text(_live(record), encoding="utf-8") ++ with pytest.raises(Timeout): ++ lock_type(lock).acquire(blocking=False) ++ ++ assert (lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*"))) == (_live(record), []) ++ ++ ++def _assert_times_out(lock_path: Path, *, timeout: float = 0.1) -> None: ++ with pytest.raises(TimeoutError): ++ SoftFileLock(lock_path, timeout=timeout).acquire() +diff --git a/tests/test_soft_stale.py b/tests/test_soft_stale.py +index ab1ef4f..fcd76e4 100644 +--- a/tests/test_soft_stale.py ++++ b/tests/test_soft_stale.py +@@ -100,7 +100,7 @@ def test_stale_lock_rename_race(tmp_path: Path, mocker: MockerFixture) -> None: + lock_path.write_text(f"{dead_pid}\n{socket.gethostname()}\n", encoding="utf-8") + + mocker.patch("filelock._soft.os.kill", side_effect=OSError(ESRCH, "No such process")) +- mocker.patch.object(Path, "rename", side_effect=FileNotFoundError("already gone")) ++ mocker.patch("filelock._soft.exchange_names", side_effect=FileNotFoundError("already gone")) + + lock = SoftFileLock(lock_path, timeout=0.1) + with pytest.raises(TimeoutError): +@@ -124,7 +124,7 @@ def test_stale_detection_errors_suppressed(tmp_path: Path, mocker: MockerFixture + lock_path = tmp_path / "test.lock" + lock_path.write_text(f"{os.getpid()}\n{socket.gethostname()}\n", encoding="utf-8") + +- mock_read: MagicMock = mocker.patch.object(Path, "read_text", side_effect=OSError("read failed")) ++ mock_read: MagicMock = mocker.patch("filelock._soft._read_lock_file", side_effect=OSError("read failed")) + + lock = SoftFileLock(lock_path, timeout=0.1) + with pytest.raises(TimeoutError): +@@ -132,11 +132,12 @@ def test_stale_detection_errors_suppressed(tmp_path: Path, mocker: MockerFixture + mock_read.assert_called() + + +-def test_write_lock_info_errors_suppressed(tmp_path: Path, mocker: MockerFixture) -> None: ++def test_write_lock_info_errors_roll_back(tmp_path: Path, mocker: MockerFixture) -> None: + lock_path = tmp_path / "test.lock" + mocker.patch("filelock._soft.os.write", side_effect=OSError("write failed")) + + lock = SoftFileLock(lock_path) +- with lock: +- assert lock.is_locked +- assert not lock_path.read_text(encoding="utf-8") ++ with pytest.raises(OSError, match="write failed"): ++ lock.acquire() ++ assert not lock.is_locked ++ assert not lock_path.exists() diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p2.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p2.patch new file mode 100644 index 0000000000..6b9c14ebdc --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p2.patch @@ -0,0 +1,90 @@ +From 5bfde2890ff907a5318c1b7f815fcd52a887f127 Mon Sep 17 00:00:00 2001 +From: Javid Khan +Date: Wed, 28 Oct 2026 11:55:11 +0530 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(soft):=20ignore=20a=20restor?= + =?UTF-8?q?e=20claim=20with=20an=20invalid=20pid=20(#871)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-112935 +Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/5bfde2890ff907a5318c1b7f815fcd52a887f127] + +Backport Changes: +- Apply recovery-claim PID validation to the older SoftFileLock and + its existing PID liveness probe. +- Restrict the upstream regression to SoftFileLock and the existing + two-line owner record. +- Omit the standalone changelog fragment, which is absent from the + 3.25.2 source archive. + +Co-authored-by: Bernát Gábor +Signed-off-by: Darsh Kelaiya +--- + src/filelock/_soft.py | 10 ++++++---- + tests/test_soft_security.py | 27 +++++++++++++++++++++++++++ + 2 files changed, 33 insertions(+), 4 deletions(-) + +diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py +index 852ac71..1681b79 100644 +--- a/src/filelock/_soft.py ++++ b/src/filelock/_soft.py +@@ -151,7 +151,8 @@ class SoftFileLock(BaseFileLock): + def _stranded_candidates(self, breaker_pid: int) -> list[tuple[str, bool]] | None: # pragma: win32 no cover + directory, name = os.path.split(self.lock_file) + stranded: Final = re.compile(rf"{re.escape(name)}\.break\.{breaker_pid}\.[0-9a-f]{{32}}") +- claimed: Final = re.compile(rf"{re.escape(name)}\.restore\.(\d+)\.[0-9a-f]{{32}}") ++ # [0-9], not \d: a str pattern's \d takes any Unicode digit, and int() reads fullwidth ones as another number. ++ claimed: Final = re.compile(rf"{re.escape(name)}\.restore\.([0-9]+)\.[0-9a-f]{{32}}") + candidates: list[tuple[str, bool]] = [] + with os.scandir(directory or os.curdir) as entries: + for entry in entries: +@@ -160,9 +161,10 @@ class SoftFileLock(BaseFileLock): + path = str(Path(directory) / entry.name) + if stranded.fullmatch(entry.name): + candidates.append((path, False)) +- elif found := claimed.fullmatch(entry.name): +- ours = int(found[1]) == os.getpid() +- if not (ours or not self._is_process_alive(int(found[1]))): ++ elif (found := claimed.fullmatch(entry.name)) and 1 <= (recoverer := int(found[1])) <= 2**31 - 1: ++ # No filelock writes a claim outside the pid range; os.kill would raise OverflowError on one. ++ ours = recoverer == os.getpid() ++ if not (ours or not self._is_process_alive(recoverer)): + return None + candidates.append((path, ours)) + return sorted(candidates) +diff --git a/tests/test_soft_security.py b/tests/test_soft_security.py +index 2a49e1f..3da9bd1 100644 +--- a/tests/test_soft_security.py ++++ b/tests/test_soft_security.py +@@ -127,3 +127,30 @@ def test_stale_break_restores_a_live_marker_a_dead_breaker_left_under_its_break_ + def _assert_times_out(lock_path: Path, *, timeout: float = 0.1) -> None: + with pytest.raises(TimeoutError): + SoftFileLock(lock_path, timeout=timeout).acquire() ++ ++ ++def _restore_name(lock: Path, pid: int | str) -> Path: # pragma: win32 no cover ++ return lock.with_name(f"test.lock.restore.{pid}.{'0' * 32}") ++ ++ ++ ++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") ++@pytest.mark.parametrize( ++ "recoverer", ++ [ ++ pytest.param(str(2**64), id="oversized"), ++ pytest.param("0", id="zero"), ++ # This process's pid in fullwidth digits, which int() reads back as the same number. ++ pytest.param("".join(chr(0xFF10 + int(digit)) for digit in str(os.getpid())), id="wide"), ++ ], ++) ++def test_stale_break_ignores_a_claim_naming_no_valid_pid( # pragma: win32 no cover ++ tmp_path: Path, lock_type: type[SoftFileLock], record: str, recoverer: str ++) -> None: ++ (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") ++ foreign: Final = record.replace(_HOST, "elsewhere") ++ (planted := _restore_name(lock, recoverer)).write_text(foreign, encoding="utf-8") ++ with lock_type(lock) as held: ++ assert held.is_locked ++ ++ assert planted.read_text(encoding="utf-8") == foreign diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p3.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p3.patch new file mode 100644 index 0000000000..ec5cf824a3 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112935_p3.patch @@ -0,0 +1,425 @@ +From 77eb01a21ce842659e7beb9fd50a3dc935647416 Mon Sep 17 00:00:00 2001 +From: Javid Khan +Date: Sat, 31 Oct 2026 20:38:25 +0530 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(soft):=20restore=20raced=20s?= + =?UTF-8?q?tale=20markers=20(#899)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-112935 +Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/77eb01a21ce842659e7beb9fd50a3dc935647416] + +Backport Changes: +- Adapt corrected exchanges and cleanup to the older descriptor + context; retain nested-breaker and refused-restore regressions. +- Add the fixed ctime-sensitive observation only for private exchange + placeholders so interrupted helpers can restore stranded holders. +- Preserve older behavior for empty and malformed user markers; omit + the newer general malformed-marker and lease APIs. +- Guard the older acquire loop when lifetime takeover already owns + the descriptor; add a regression for explicit read-only modes. +- Adapt upstream fixtures and imports for the older test harness. +- Omit the standalone changelog and concepts documentation, which + are absent from the 3.25.2 source archive. + +Co-authored-by: Bernát Gábor +Signed-off-by: Darsh Kelaiya +--- + src/filelock/_soft.py | 57 ++++++++++++++- + src/filelock/_util.py | 36 ++++++++- + tests/test_soft_security.py | 175 +++++++++++++++++++++++++++++++++++++++++++- + 3 files changed, 264 insertions(+), 4 deletions(-) + +diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py +index 1681b79..dd076af 100644 +--- a/src/filelock/_soft.py ++++ b/src/filelock/_soft.py +@@ -17,6 +17,7 @@ from ._api import BaseFileLock + from ._util import ( + break_lock_file, + ensure_directory_exists, ++ exchange_back, + exchange_names, + exchange_supported, + pin_inode, +@@ -27,6 +28,7 @@ from ._util import ( + + _WIN_SYNCHRONIZE = 0x100000 + _WIN_ERROR_INVALID_PARAMETER = 87 ++MALFORMED_LOCK_AGE_THRESHOLD: Final[float] = 2.0 + _MAX_LOCK_FILE_SIZE: Final[int] = 1024 + #: Contenders act on the record in the marker, so no other account may write it, whatever the mode or umask. + _OTHERS_WRITE: Final[int] = stat.S_IWGRP | stat.S_IWOTH +@@ -46,7 +48,12 @@ class SoftFileLock(BaseFileLock): + + """ + ++ _marker_seen: tuple[tuple[float, int, int], float] | None = None ++ + def _acquire(self) -> None: ++ # The 3.25 acquire loop calls us even after the expiry hook claimed the marker. ++ if self.is_locked: ++ return + raise_on_not_writable_file(self.lock_file) + ensure_directory_exists(self.lock_file) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC +@@ -81,6 +88,11 @@ class SoftFileLock(BaseFileLock): + if content == _RELEASED_RECORD: + self._take_over(marker, None) + return ++ if (breaker := _placeholder_breaker(content)) is not None: ++ # Only our private exchange placeholder is aged out; malformed legacy markers stay held. ++ if self._marker_unchanged_for(marker) >= MALFORMED_LOCK_AGE_THRESHOLD: ++ self._take_over(marker, breaker) ++ return + holder = _parse_lock_holder(content) + if holder is None: + # 3.25 does not age out malformed or half-written markers. +@@ -89,6 +101,19 @@ class SoftFileLock(BaseFileLock): + if hostname == socket.gethostname() and not self._is_process_alive(pid): + self._take_over(marker, pid) + ++ def _marker_unchanged_for(self, marker: os.stat_result) -> float: ++ # The mtime is the writer's or file server's clock, so against time.time() a contender whose clock runs ahead ++ # would expire a fresh marker. Only watch it for change, timed on this process's monotonic clock. The owner of a ++ # file sets its mtime, and ext4 reuses a freed inode number, so a new marker can repeat both. The change time ++ # cannot be set and moves with every create, which keeps a new marker from inheriting an old one's age. ++ now: Final = time.monotonic() ++ key: Final = marker.st_mtime, marker.st_ino, marker.st_ctime_ns ++ if (seen := self._marker_seen) is None or seen[0] != key: ++ self._marker_seen = key, now ++ return 0.0 ++ return now - seen[1] ++ ++ + def _try_break_expired_lock(self) -> None: + if (lifetime := self._context.lifetime) is None: + return +@@ -122,6 +147,10 @@ class SoftFileLock(BaseFileLock): + exchanged = held = False + try: + self._write_lock_info(fd) ++ # The scan and the write take long enough for a peer to have taken the marker since the check above, and an ++ # exchange now would move that peer's live marker aside. ++ if (current := os.lstat(self.lock_file)).st_mtime != marker.st_mtime or current.st_ino != marker.st_ino: ++ return + if not exchange_names(aside, self.lock_file): + return # NFS, SMB and the like refuse it; renaming the marker aside would empty the path + exchanged = True +@@ -222,14 +251,17 @@ class SoftFileLock(BaseFileLock): + + def _abandon_take_over(self, fd: int, aside: str, *, put_back: bool) -> None: # pragma: win32 no cover + self._context.lock_file_fd = None ++ put_back_done = not put_back + try: + if put_back: +- exchange_names(aside, self.lock_file) ++ put_back_done = exchange_back(self.lock_file, aside, fd) + finally: + try: + # Whichever name still holds our marker loses it: the descriptor keeps its inode number ours. + own: Final = os.fstat(fd) + for name in (self.lock_file, aside): ++ if name == self.lock_file and not put_back_done: ++ continue + with suppress(FileNotFoundError): + if os.path.samestat(os.lstat(name), own): + Path(name).unlink() +@@ -399,4 +431,27 @@ def _parse_lock_holder(content: str | None) -> tuple[int, str] | None: + return (pid, hostname) if 1 <= pid <= 2**31 - 1 else None + + ++def _placeholder_breaker(content: str | None) -> int | None: # pragma: win32 no cover ++ # The placeholder a break leaves at the path while it works names the breaker that may have died there. ++ if content is None or not content.startswith(placeholder := "filelock/break\n") or not content.endswith("\n"): ++ return None ++ with suppress(ValueError): ++ return parse_decimal(content.removeprefix(placeholder)[:-1]) ++ return None ++ ++ ++def parse_decimal(text: str) -> int: ++ """ ++ Read a PID or start token as a writer formats it: ASCII digits only. ++ ++ :raises ValueError: for anything else ``int()`` would accept, such as ``1_000``, surrounding whitespace, a sign or ++ non-ASCII digits; no filelock writes those ++ ++ """ ++ if not (text.isascii() and text.isdigit()): ++ msg = f"not a decimal integer: {text!r}" ++ raise ValueError(msg) ++ return int(text) ++ ++ + __all__ = ["SoftFileLock"] +diff --git a/src/filelock/_util.py b/src/filelock/_util.py +index 98d4f6f..55990c3 100644 +--- a/src/filelock/_util.py ++++ b/src/filelock/_util.py +@@ -6,6 +6,7 @@ import platform + import secrets + import stat + import sys ++import time + from contextlib import contextmanager, suppress + from errno import EACCES, EINVAL, EIO, EISDIR, ENOSYS, EXDEV + from functools import cache +@@ -195,8 +196,7 @@ def _swap_out(lock_file: str, break_path: str, mtime_before: float, ino_before: + if os.path.samestat(os.lstat(lock_file), os.fstat(fd)): + Path(lock_file).unlink() + else: +- exchange_names(break_path, lock_file) +- Path(break_path).unlink() ++ exchange_back(lock_file, break_path, fd) + finally: + os.close(fd) + +@@ -306,8 +306,40 @@ def _renameat2(libc: ctypes.CDLL) -> Callable[[bytes, bytes, int], int] | None: + # the lock counts as held. + _MOVES_WITHOUT_EXCHANGE: Final[bool] = sys.platform == "win32" + ++def exchange_back(lock_file: str, aside: str, fd: int) -> bool: # pragma: win32 no cover ++ # Raced breakers nest: each aside holds the prior marker. Returning out of order can strand a live holder's marker. ++ # A peer may move the path between our check and exchange, so stalled breakers exchange to open any resulting ring. ++ try: ++ settled = _exchange_until_aside(lock_file, aside, os.fstat(fd)) ++ except FileNotFoundError: ++ settled = True ++ if settled: ++ Path(aside).unlink(missing_ok=True) ++ return settled ++ ++ ++def _exchange_until_aside(lock_file: str, aside: str, own: os.stat_result) -> bool: # pragma: win32 no cover ++ # Peers need a few system calls to unwind; a paused or dead peer must not block the caller indefinitely. ++ deadline: Final = time.monotonic() + 2.0 ++ stall: Final = 0.01 ++ feed_at = time.monotonic() + stall ++ while not os.path.samestat(os.lstat(aside), own): ++ now = time.monotonic() ++ ours = os.path.samestat(os.lstat(lock_file), own) ++ if not ours and now >= deadline: ++ return False ++ if ours or now >= feed_at: ++ if not exchange_names(aside, lock_file): ++ return False ++ feed_at = now + stall ++ else: ++ time.sleep(0.0005) ++ return True ++ ++ + __all__ = [ + "break_lock_file", ++ "exchange_back", + "exchange_names", + "exchange_supported", + "pin_inode", +diff --git a/tests/test_soft_security.py b/tests/test_soft_security.py +index 3da9bd1..0923b23 100644 +--- a/tests/test_soft_security.py ++++ b/tests/test_soft_security.py +@@ -1,15 +1,20 @@ + from __future__ import annotations + ++import itertools + import os + import socket + import sys + import time ++from contextlib import suppress + from pathlib import Path + from typing import TYPE_CHECKING, Final + + import pytest + ++import filelock ++ + from filelock import SoftFileLock, Timeout ++from filelock._soft import MALFORMED_LOCK_AGE_THRESHOLD + from filelock._util import exchange_names, exchange_supported + + if TYPE_CHECKING: +@@ -133,7 +138,6 @@ def _restore_name(lock: Path, pid: int | str) -> Path: # pragma: win32 no cover + return lock.with_name(f"test.lock.restore.{pid}.{'0' * 32}") + + +- + @pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") + @pytest.mark.parametrize( + "recoverer", +@@ -154,3 +158,172 @@ def test_stale_break_ignores_a_claim_naming_no_valid_pid( # pragma: win32 no co + assert held.is_locked + + assert planted.read_text(encoding="utf-8") == foreign ++ ++ ++def _break_contents(lock: Path) -> list[str]: # pragma: win32 no cover ++ return [path.read_text(encoding="utf-8") for path in lock.parent.glob(f"{lock.name}.break.*")] ++ ++ ++def _lose_to_a_live_holder( # pragma: win32 no cover ++ lock: Path, live: str, mocker: MockerFixture, then: Callable[[str, str], bool | None] ++) -> None: ++ (holder := lock.with_name("holder")).write_text(live, encoding="utf-8") ++ ++ def exchange_after_the_holder_won(aside: str, path: str) -> bool: ++ mocker.patch("filelock._soft.exchange_names", new=exchange_names) ++ holder.replace(lock) ++ exchange_names(aside, path) ++ then(aside, path) ++ return True ++ ++ mocker.patch("filelock._soft.exchange_names", new=exchange_after_the_holder_won) ++ ++ ++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") ++def test_stale_break_exchanges_back_after_the_breaker_that_took_its_marker( # pragma: win32 no cover ++ tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ++) -> None: ++ (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") ++ (peer := tmp_path / "peer").write_text(record.replace(_HOST, "elsewhere"), encoding="utf-8") ++ ++ def peer_exchanges_back(_seconds: float) -> None: ++ exchange_names(str(peer), str(lock)) ++ peer.unlink() ++ ++ _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, path: exchange_names(str(peer), path)) ++ mocker.patch("filelock._util.time", wraps=time, sleep=mocker.Mock(side_effect=peer_exchanges_back)) ++ with pytest.raises(Timeout): ++ lock_type(lock).acquire(blocking=False) ++ ++ assert (lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (_live(record), [lock]) ++ ++ ++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") ++def test_stale_break_opens_a_ring_of_breakers_holding_each_others_markers( # pragma: win32 no cover ++ tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ++) -> None: ++ (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") ++ foreign: Final = record.replace(_HOST, "elsewhere") ++ (peer := tmp_path / "peer").write_text(foreign, encoding="utf-8") ++ ++ def peer_then_our_exchange_out_of_turn(aside: str, path: str) -> None: ++ exchange_names(str(peer), path) ++ exchange_names(aside, path) ++ ++ def peer_exchanges_back_once_it_stands_at_the_path(_seconds: float) -> None: ++ if lock.read_text(encoding="utf-8") == foreign: ++ exchange_names(str(peer), str(lock)) ++ peer.unlink() ++ ++ _lose_to_a_live_holder(lock, _live(record), mocker, peer_then_our_exchange_out_of_turn) ++ mocker.patch( ++ "filelock._util.time", wraps=time, sleep=mocker.Mock(side_effect=peer_exchanges_back_once_it_stands_at_the_path) ++ ) ++ with pytest.raises(Timeout): ++ lock_type(lock).acquire(blocking=False) ++ ++ assert (lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (_live(record), [lock]) ++ ++ ++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") ++def test_stale_break_keeps_every_name_when_the_path_never_comes_back( # pragma: win32 no cover ++ tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ++) -> None: ++ (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") ++ foreign: Final = record.replace(_HOST, "elsewhere") ++ (peer := tmp_path / "peer").write_text(foreign, encoding="utf-8") ++ _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, path: exchange_names(str(peer), path)) ++ with pytest.raises(Timeout): ++ lock_type(lock).acquire(blocking=False) ++ ++ assert sorted([lock.read_text(encoding="utf-8"), *_break_contents(lock)]) == sorted([foreign, _live(record)]) ++ ++ ++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") ++def test_stale_break_keeps_its_marker_when_exchange_back_is_refused( # pragma: win32 no cover ++ tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ++) -> None: ++ (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") ++ _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, _path: None) ++ mocker.patch("filelock._util.exchange_names", side_effect=[False, AssertionError("retried refused exchange")]) ++ with pytest.raises(Timeout): ++ lock_type(lock).acquire(blocking=False) ++ ++ assert (f"{os.getpid()}\n" in lock.read_text(encoding="utf-8"), _break_contents(lock)) == ( ++ True, ++ [_live(record)], ++ ) ++ ++ ++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") ++def test_stale_break_drops_what_it_took_when_the_holder_released_the_path( # pragma: win32 no cover ++ tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture ++) -> None: ++ (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") ++ _lose_to_a_live_holder(lock, _live(record), mocker, lambda _aside, path: Path(path).unlink()) ++ with pytest.raises(Timeout): ++ lock_type(lock).acquire(blocking=False) ++ ++ assert list(tmp_path.iterdir()) == [] ++ ++ ++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") ++def test_stale_break_leaves_a_marker_taken_since_its_first_look_on_the_path( # pragma: win32 no cover ++ tmp_path: Path, ++ lock_type: type[SoftFileLock], ++ record: str, ++ mocker: MockerFixture, ++ on_break_recheck: Callable[[Path, Callable[[], None]], None], ++) -> None: ++ (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") ++ (holder := tmp_path / "holder").write_text(_live(record), encoding="utf-8") ++ exchange: Final = mocker.spy(filelock._soft, "exchange_names") ++ ++ def peer_takes_the_marker() -> None: ++ holder.replace(lock) ++ ++ on_break_recheck(lock, peer_takes_the_marker) ++ with pytest.raises(Timeout): ++ lock_type(lock).acquire(blocking=False) ++ ++ assert (exchange.call_count, lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == ( ++ 0, ++ _live(record), ++ [lock], ++ ) ++ ++ ++@pytest.fixture ++def unchanged_past_grace(mocker: MockerFixture) -> None: ++ # Each look at the marker lands a full grace window after the previous one on the observer's clock, so an unchanged ++ # malformed marker ages out on the second look without the test sleeping through the window. ++ mocker.patch("filelock._soft.time.monotonic", side_effect=itertools.count(step=MALFORMED_LOCK_AGE_THRESHOLD)) ++ ++ ++@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") ++@pytest.mark.usefixtures("unchanged_past_grace") ++def test_stale_break_restores_a_marker_a_dead_breaker_left_beside_its_placeholder( # pragma: win32 no cover ++ tmp_path: Path, lock_type: type[SoftFileLock], record: str ++) -> None: ++ (lock := tmp_path / "test.lock").write_text(f"filelock/break\n{_DEAD_PID}\n", encoding="utf-8") ++ _break_name(lock).write_text(_live(record), encoding="utf-8") ++ contender: Final = lock_type(lock) ++ for _ in range(2): ++ with suppress(Timeout): ++ contender.acquire(blocking=False) ++ ++ assert (contender.is_locked, lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*"))) == ( ++ False, ++ _live(record), ++ [], ++ ) ++ ++ ++def test_expired_takeover_with_read_only_explicit_mode(tmp_path: Path) -> None: ++ lock_path = tmp_path / "test.lock" ++ lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") ++ os.utime(lock_path, (0, 0)) ++ lock = SoftFileLock(lock_path, mode=0o400, lifetime=1, timeout=0) ++ with lock: ++ assert lock.is_locked ++ assert lock_path.stat().st_mode & 0o777 == 0o400 diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb index 03c23092b9..352ab29a29 100644 --- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb +++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb @@ -9,6 +9,9 @@ CVE_PRODUCT = "tox-dev:filelock" SRC_URI += " file://run-ptest \ file://CVE-2026-112939.patch \ + file://CVE-2026-112935_p1.patch \ + file://CVE-2026-112935_p2.patch \ + file://CVE-2026-112935_p3.patch \ " SRC_URI[sha256sum] = "b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694" @@ -21,6 +24,7 @@ DEPENDS += "\ " RDEPENDS:${PN} += " \ + python3-ctypes \ python3-core \ python3-logging \ python3-asyncio \ From patchwork Wed Nov 11 05:29:32 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" X-Patchwork-Id: 103662 Received: from rcdn-iport-2.cisco.com (rcdn-iport-2.cisco.com [173.37.86.73]) by mx.groups.io with SMTP id smtpd.msgproc02-g2.3284.1794374984292199101 for ; Tue, 10 Nov 2026 21:29:44 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=WQKpVdhs; spf=pass (domain: cisco.com, ip: 173.37.86.73, mailfrom: dkelaiya@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=18284; q=dns/txt; s=iport01; t=1794374984; x=1795584584; h=from:to:cc:subject:date:message-id:in-reply-to: references:mime-version:content-transfer-encoding; bh=5t6KSy7vfPdKsXCtgz/8/DBoScy61xhT9/m3PJr2v4g=; b=WQKpVdhsy/Fi72Ob8/RZQCRmwRdyNnUcyunHPeuL1GaR5tBvIlwEX+pu +/ikuvwkkAswp1zRJHMp5m0wDbEo+oPr+ih55EbG9EnXTDzNMQv4ZEtZ/ sipN+1mh0HOMLUTiAU0t91a45aGBdXPDDWDd04/7glX7PGnAuVYP9pp7N wVvsIM7W70pwnKlW7KRWK5vKApR83h4ke7iX1dwj2BHhIjuThuPmyzxcQ eYDAI0sCLcEaczmmVwbweBEmM1MeVOfvp5vaqYdD4bJ0aaYIJCb2WYaiI JWaxp1yy9Ul2W0qVzMOYeim1NJdvcNGFqLDp/uokMyCOYhGc2GSDm/QXn A==; X-CSE-ConnectionGUID: cN0NEegrT6OGN891R6Xucg== X-CSE-MsgGUID: 59Ys0ctjQniYGxDfrsRoRg== X-IPAS-Result: A0BSAgDP+/Nq/5L/Ja1RCYEVgUSCV3VgQ0kDhFSRdAOBE4pSkj0UgWoPAQEBD0QNBAEBhD9GAk8VjToCJjQJDgECBAMCAwEBAQEBAQEBAQEBAQoBAQUBAQECAQcFgQ4Thk8NkBIBAgEDIwQLARgBLRAcAwECAwImAgIgCx0BBQgQCR6CZAGCOgM3AxEFuVF6fzOBAYMpATEFgR7YTA2CWgELFAGBCi6FQoMAIAGFA10YAYR9JxsbgXKBFYJzdoEFgRpCAoE0EoN2gkgiBIIiehKBWoECB4UzA4ZbAoFPhyVIgQIcA1ksAVUTDQoLBwWBaAM1EioVbjIdgSM+Fy9YGwYFgRyBHoESgQddgSsoXQEhgQiBdgKCUYIAAgFJQQ4HRVMJJUMSRyQiCBIJARMaLAqBGjU8CSY/FQxnCxoFYQgCDQx0ORAwHiQvBRomPhsKfhgNSBEsNxUZBD0BbgeSboMVexMBJgUEEzkEKxIJBg89GAchHAIELJJDHQkIkBaCIYE1nmtxCiiDeIwkjz6FfBozhASBV5JDklQLmH2OCoQKhQeMWBtNhGqBaDyBWXAVO4JnCUoZD1cBjVYLC4Ngg1LLCSYyAgkDLwEBBwIHDgMLgWiQACxyYAEB IronPort-Data: A9a23:jsHDFaOT8ubntyfvrR31lsFynXyQoLVcMsEvi/4bfWQNrUpw1mYCy jQYCm2CO62NNDOgedh+bI2/8xsCvcSGzNFkS3M5pCpnJ55oRWUpJjg4wmPYZX76whjrFRo/h ykmQoCeap1yFjmD9k/F3oHJ9RFUzbuPSqf3FNnKMyVwQR4MYCo6gHqPocZh6mJTqYb/WVzlV e/a+ZWFZgb8gmcsawr41orawP9RlKWq0N8nlgRWicBj5Df2i3QTBZQDEqC9R1OQapVUBOOzW 9HYx7i/+G7Dlz91Yj9yuu+mGqGiaue60Tmm0hK6aYD76vRxjnBaPpIACRYpQRw/ZwNlMDxG4 I4lWZSYEW/FN0BX8QgXe0Ew/ypWZcWq9FJbSJSymZT78qHIT5fj6/5IVGcbMIICxrhQLkcep KUidgAQZR/W0opawJrjIgVtrt4oIM+uOMYUvWttiGmIS/0nWpvEBa7N4Le03h9p2ZsIRqmYP ZdEL2MzMnwsYDUXUrsTIJ49keOhh2j2WzZZs1mS46Ew5gA/ySQsi+K3boCIJIPiqcN9wxepv TrN42bABBArG8yzxyCeyHWWibqa9c/8cMdIfFGizdZtmFCVy2kZBREaWFf+rfSnh0qWX9NEN 1dS/TIjq6U3/kGnQtTxGRqirxa5UgU0QdFcFag+rQqK0KeRu1ffDWkfRTkHY9sj3CMreQEXO payt4uBLVRSXHe9EBpxKp/8QeuOBBUo IronPort-HdrOrdr: A9a23:yb1smKFyP+IeP6kGpLqEMMeALOsnbusQ8zAXPo5KJiC9Ffbo8f xG/c5rsiMc5wxxZJhNo7290ey7MBHhHP1OkO0s1MmZPDUO0VHAROoJ0WKh+UyEJ8SUzIBgPM lbH5SWcOeAbmRSvILd/BSyFcomzZ2s9aClgvqb8lJWJDsaEp2JK2xCe32m+oocfng/OaYE X-Talos-CUID: 9a23:mrcQOG4SkAiJkyerE9ss/2hMPO0ofWfhklD/BR/pUFw3eKCeRgrF X-Talos-MUID: 9a23:DzA0yA8xgcfdKJHSlrui8k2Qf5o50r/2A1wPqJMHiuaUGSJqIA2atjviFw== X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,207,1787011200"; d="scan'208";a="532262567" Received: from rcdn-l-core-09.cisco.com ([173.37.255.146]) by rcdn-iport-2.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 11 Nov 2026 05:29:43 +0000 Received: from sjc-ads-5675.cisco.com (sjc-ads-5675.cisco.com [10.28.88.189]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by rcdn-l-core-09.cisco.com (Postfix) with ESMTPS id 09B3318000238; Wed, 11 Nov 2026 05:29:43 +0000 (GMT) Received: by sjc-ads-5675.cisco.com (Postfix, from userid 1887444) id AA36ECBF203; Tue, 10 Nov 2026 21:29:42 -0800 (PST) From: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" To: openembedded-devel@lists.openembedded.org Cc: Darsh Kelaiya Subject: [oe][meta-python][wrynose][PATCH 3/9] python3-filelock: fix CVE-2026-112909 Date: Tue, 10 Nov 2026 21:29:32 -0800 Message-Id: <20261111052938.3691463-4-dkelaiya@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261111052938.3691463-1-dkelaiya@cisco.com> References: <20261111052938.3691463-1-dkelaiya@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: ANONYMOUS;sjc-ads-5675.cisco.com [10.28.88.189];TLSv1.3;TLS_AES_256_GCM_SHA384;256 X-Outbound-SMTP-Client: 10.28.88.189, sjc-ads-5675.cisco.com X-Outbound-Node: rcdn-l-core-09.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Wed, 11 Nov 2026 05:29:50 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/132116 From: Darsh Kelaiya Backport the UnixFileLock integrity fix [1] referenced by advisory [2]. Open existing lock files without truncation and apply explicit modes only to files created by this acquisition. Keep descriptors pinned through ownership-aware fallback cleanup. Include the required ENOSYS ownership handling [3] and the focused upstream descriptor-mock correction [4]. Reuse the preceding atomic helpers and preserve the existing 3.25.2 public API and FUSE retries. [1] https://github.com/tox-dev/filelock/commit/e1e825b1992f69fb6b346dc395d5ae4893536c0a [2] https://github.com/tox-dev/filelock/security/advisories/GHSA-69hj-f68j-2879 [3] https://github.com/tox-dev/filelock/commit/17a7c3182674601998588eb7aad6ec765c959251 [4] https://github.com/tox-dev/filelock/commit/65f5ea039c30ac6a77f09a88c1920503bff10a82 Signed-off-by: Darsh Kelaiya --- .../python3-filelock/CVE-2026-112909_p1.patch | 312 ++++++++++++++++++ .../python3-filelock/CVE-2026-112909_p2.patch | 53 +++ .../python/python3-filelock_3.25.2.bb | 2 + 3 files changed, 367 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112909_p1.patch create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112909_p2.patch diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112909_p1.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112909_p1.patch new file mode 100644 index 0000000000..b145023a31 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112909_p1.patch @@ -0,0 +1,312 @@ +From e1e825b1992f69fb6b346dc395d5ae4893536c0a Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +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 +--- + 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*")] diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112909_p2.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112909_p2.patch new file mode 100644 index 0000000000..62bff82a46 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112909_p2.patch @@ -0,0 +1,53 @@ +From 65f5ea039c30ac6a77f09a88c1920503bff10a82 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +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 +--- + 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() diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb index 352ab29a29..a4bd002f1f 100644 --- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb +++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb @@ -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" From patchwork Wed Nov 11 05:29:33 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" X-Patchwork-Id: 103661 Received: from rcdn-iport-4.cisco.com (rcdn-iport-4.cisco.com [173.37.86.75]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.3364.1794374983916368146 for ; Tue, 10 Nov 2026 21:29:44 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=Gn3XZNYE; spf=pass (domain: cisco.com, ip: 173.37.86.75, mailfrom: dkelaiya@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=16510; q=dns/txt; s=iport01; t=1794374984; x=1795584584; h=from:to:cc:subject:date:message-id:in-reply-to: references:mime-version:content-transfer-encoding; bh=3y6s7aHkBRDyFfoLwWAj6cL58Oyr25p+gtwDfmobaZU=; b=Gn3XZNYEou2kZmw7zc8gUCVDWKroB9egrxGvJW8+AZTwykWyOE77bDhT Fud2VN5c8SmNQq8a/hM8bSbi7Q+c2pTeU8kOz3kUIvlypcH0rAlRdF1Wd ZCeaFvDX38dRzWYKuY6JZGamtK6E7uzgLIbS4OoWH8NNILPBWCG75WQnv mLFAH9l9QuiEB8JiP6PdYXZ6z1M2nSlYks6plV6CSRhhDNKS2/YmkT1Qg xZHzP5dkCZtv3FSAEES77qj2IE1S39optqZerX5+e2tb5dWgWxEO5erXd Qx0iMpSd4MMOc05ko3TxY1/OCuf+6Mf+g1CWb2gs8EyVgvPOKAyZ61Nkf Q==; X-CSE-ConnectionGUID: Q+E3KhbBQLSXgLSyWifcEA== X-CSE-MsgGUID: Wb0HGT6QTTKuBuRVdZu6Qg== X-IPAS-Result: A0BBAwDN/PNq/43/Ja1RAQiCWYJXdWBDSQOWSAOBE4pSlDsPAQEBD0QNBAEBhD9GAo4eAiY3Bg4BAgQDAgMBAQEBAQEBAQEBAQEKAQEFAQEBAgEHBYEOE4ZPDZASAQIBAycLARgBLRAcAwECLyALHQEFCBkZgmkBgjoDNwMRuV6BeTOBAYMpAYFU2EwNgloBCxQBgTiFQoMAhSRdGAFEhDknGxuBcoEVgnN2gQWBGkICAYEzBYZLIgSCDRV6EoFaHmQJghmJdgKBT4clSIEeA1ksAVUTDQoLBwWBaAM1EioVbjIdgSM+F4EHGwYFgRyBHoESgQddgSsoXQEhgQiBdgKCUYIAAgFJQQ4HRVMJJUMSRyQiCBIJARMaLAqBGjU8CSY/FQxnCxoFYQgCDQx0ORAwHiQvBRomPhsKfhgNSBEsNxUZBD5uB5JugxUBgQ0BJQEDAlAEPTwwCyQPP5ALglUHkCCCIYE1nmtxCiiDeIwkjz6FfBozhASBV5JDklQLmH2OCoQKkV8bTYRqgX4mRoETcBU7gmcJShkPWI1hg2uDPoESQ8lIJjICCQMvAQEHAgcOAwuBaJACLYFPAQE IronPort-Data: A9a23:7fLvWK/+ItGJCTtbYnyuDrUD0H+TJUtcMsCJ2f8bNWPcYEJGY0x3y mofXGGEOfbYMWGkc4pxPYW/o09Q7MKGx4AxHQdupXtEQiMRo6IpJzg2wmQcns+2BpeeJK6yx 5xGMrEsFOhtEDmB4E/rbei5xZVF/fngbqLmD+LZMTxGSwZhSSMw4TpugOdRbrRA2bBVOCvT/ 4mtyyHjEAX9gWAtaztNs/vrRC5H5ZwehhtJ5jTSWtgT1LPuvyF9JI4SI6i3M0z5TuF8dsamR /zOxa2O5WjQ+REgELuNyt4XpWVTH9Y+lSDX4pZnc/DKbipq/0Te4Y5nXBYoUnq7vh3S9zxHJ HqhgrTrIeshFvWkdO3wyHC0GQkmVUFN0OevzXRSLaV/wmWeG0YAzcmCA2kPfqBD2eZ9K1hqz swXDAscURGNjM+flefTpulE3qzPLeHxN48Z/3UlxjbDALN+HdbIQr7B4plT2zJYasJmRKmFI ZFGL2AyMVKZP0Qn1lQ/UPrSmM+khXT7ejxJoXqepLE85C7YywkZPL3FbYaLJ4bbHZ8F9qqej l3Mp0TWPk8jCOC81BCYyEygtPKRpCyuDer+E5X9rJaGmma7wXQeDhATX1a3rfS1z0KzRd9bA 0gV4TY1668q+UqmS9PwUxG1rDiDpBF0ZjZLO/cx5AfIzu/f5ByUQzBYCDVAc9ch8sQxQFTGy 2O0oj8gPhQ32JX9dJ5X3u38Qe+aUcTNEVI/WA== IronPort-HdrOrdr: A9a23:8XHGbqlusdCVLQUFzfL87BAct+7pDfIO3DAbv31ZSRFFG/FwWf rAoB19726StN9/YhAdcLy7VZVoBEmsl6KdgrNhWYtKIjOHhILAFugLhuHfKn/bakjDH4Vmu5 uIHZITNDSJNykYse/KpC+lDt0n3N6LtIqshevY0jNRaDsCUdAH0++8YTzranGfg2J9dOMEKK Y= X-Talos-CUID: 9a23:skPUq2HX022zFff6qmJet2NPOcAvWET590ffB3SpNmdnQ5C8HAo= X-Talos-MUID: 9a23:/Ss7/gSFFBCGncTbRXTTjmlCG/s22piSK10njK84t/uZaxV/bmI= X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,207,1787011200"; d="scan'208";a="546529028" Received: from rcdn-l-core-04.cisco.com ([173.37.255.141]) by rcdn-iport-4.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 11 Nov 2026 05:29:43 +0000 Received: from sjc-ads-5675.cisco.com (sjc-ads-5675.cisco.com [10.28.88.189]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by rcdn-l-core-04.cisco.com (Postfix) with ESMTPS id 108B8180015C0; Wed, 11 Nov 2026 05:29:43 +0000 (GMT) Received: by sjc-ads-5675.cisco.com (Postfix, from userid 1887444) id AF188CBF204; Tue, 10 Nov 2026 21:29:42 -0800 (PST) From: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" To: openembedded-devel@lists.openembedded.org Cc: Darsh Kelaiya Subject: [oe][meta-python][wrynose][PATCH 4/9] python3-filelock: fix CVE-2026-112936 Date: Tue, 10 Nov 2026 21:29:33 -0800 Message-Id: <20261111052938.3691463-5-dkelaiya@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261111052938.3691463-1-dkelaiya@cisco.com> References: <20261111052938.3691463-1-dkelaiya@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: ANONYMOUS;sjc-ads-5675.cisco.com [10.28.88.189];TLSv1.3;TLS_AES_256_GCM_SHA384;256 X-Outbound-SMTP-Client: 10.28.88.189, sjc-ads-5675.cisco.com X-Outbound-Node: rcdn-l-core-04.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Wed, 11 Nov 2026 05:29:50 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/132115 From: Darsh Kelaiya Backport the POSIX ReadWriteLock symlink checks [1] referenced by advisory [2]. Resolve only the parent directory and reject final symlinks or non-regular database paths before opening SQLite. Adapt descriptor lifetime handling from [3] for 3.25.2's persistent connections so closing a probe cannot drop a peer's POSIX lock. Keep the existing public interfaces and add SQLite to the ptest dependencies to exercise the read-write security regressions. [1] https://github.com/tox-dev/filelock/commit/4d45e83caf7e31f9a12fb44526bdc07f70114f04 [2] https://github.com/tox-dev/filelock/security/advisories/GHSA-j8f7-rjxc-mr56 [3] https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d Signed-off-by: Darsh Kelaiya --- .../python3-filelock/CVE-2026-112936.patch | 382 ++++++++++++++++++ .../python/python3-filelock_3.25.2.bb | 2 + 2 files changed, 384 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112936.patch diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112936.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112936.patch new file mode 100644 index 0000000000..e4bbc9bd41 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112936.patch @@ -0,0 +1,382 @@ +From 4d45e83caf7e31f9a12fb44526bdc07f70114f04 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Thu, 1 Oct 2026 13:07:38 -0700 +Subject: [PATCH] Merge commit from fork + +ReadWriteLock resolved its path with Path.resolve() and handed it to +sqlite3.connect(), so a symlink planted in a shared lock directory made +the victim open, lock, or create the link's target, and repointing the +link between two constructions let two writers hold the lock at once. + +Resolve only the parent directory and open the database through a +descriptor that refuses a symlink: O_NOFOLLOW and a regular-file check +on POSIX, with SQLite connecting via /proc/self/fd or /dev/fd, and on +Windows a handle that refuses reparse points and denies delete sharing. +Holding that descriptor for the connection's life keeps the name pinned +to the file that was checked. + +GHSA-j8f7-rjxc-mr56 + +CVE: CVE-2026-112936 +Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/4d45e83caf7e31f9a12fb44526bdc07f70114f04] + +Backport Changes: +- Adapt parent-only canonicalization and descriptor opening to the + 3.25.2 constructor without changing public method signatures. +- Keep compatibility helpers in the new private _rw_security.py. +- Adapt the later upstream inode-probe pool as a prerequisite for + the following shared-path fix and long-lived peer connections. +- Validate each opened database once and retain its inode identity. +- Close descriptors after failed fstat; defer unknown-inode closes + until all tracked connections close to preserve POSIX locks. +- Add failed-fstat, non-regular path, symlink and peer-lock cases + to a dedicated test_read_write_security.py regression file. +- Preserve optional SQLite imports in the regression module. +- Preserve existing Windows opening behavior; omit new handle APIs + and newer fork tests because this recipe targets POSIX systems. +- Omit standalone changelog fragments and newer documentation + context not present in the 3.25.2 release source. + +Signed-off-by: Darsh Kelaiya +--- + src/filelock/_read_write.py | 6 +- + src/filelock/_rw_security.py | 171 ++++++++++++++++++++++++++++++ + tests/test_read_write_security.py | 120 +++++++++++++++++++++ + 3 files changed, 295 insertions(+), 2 deletions(-) + create mode 100644 src/filelock/_rw_security.py + create mode 100644 tests/test_read_write_security.py + +diff --git a/src/filelock/_read_write.py b/src/filelock/_read_write.py +index 9e26cbf..e8b6420 100644 +--- a/src/filelock/_read_write.py ++++ b/src/filelock/_read_write.py +@@ -13,6 +13,7 @@ from weakref import WeakValueDictionary + + from ._api import AcquireReturnProxy + from ._error import Timeout ++from ._rw_security import canonical, connect + + if TYPE_CHECKING: + from collections.abc import Generator +@@ -79,7 +80,7 @@ class _ReadWriteLockMeta(type): + if not is_singleton: + return super().__call__(lock_file, timeout, blocking=blocking, is_singleton=is_singleton) + +- normalized = pathlib.Path(lock_file).resolve() ++ normalized = canonical(lock_file) + with cls._instances_lock: + if normalized not in cls._instances: + instance = super().__call__(lock_file, timeout, blocking=blocking, is_singleton=is_singleton) +@@ -154,7 +155,8 @@ class ReadWriteLock(metaclass=_ReadWriteLockMeta): + self._lock_level = 0 + self._current_mode: Literal["read", "write"] | None = None + self._write_thread_id: int | None = None +- self._con = sqlite3.connect(self.lock_file, check_same_thread=False) ++ self._canonical_path = canonical(lock_file) ++ self._con = connect(os.fspath(self._canonical_path)) + with _all_connections_lock: + _all_connections.add(self._con) + +diff --git a/src/filelock/_rw_security.py b/src/filelock/_rw_security.py +new file mode 100644 +index 0000000..6916212 +--- /dev/null ++++ b/src/filelock/_rw_security.py +@@ -0,0 +1,171 @@ ++from __future__ import annotations ++ ++import os ++import pathlib ++import sqlite3 ++import stat ++import sys ++import threading ++from collections import deque ++from contextlib import suppress ++from dataclasses import dataclass, field ++from itertools import count ++from typing import NamedTuple ++ ++_DatabaseIdentity = tuple[int, int] ++_DB_OPEN_FLAGS = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) ++_FD_DIR = "/proc/self/fd" if sys.platform == "linux" else "/dev/fd" ++ ++ ++def canonical(database: str | os.PathLike[str]) -> pathlib.Path: ++ # Resolve only the parent, so a symlink at the lock path stays its own key instead of aliasing its target. ++ path = pathlib.Path(database) ++ return path.parent.resolve() / path.name ++ ++ ++class _Probe(NamedTuple): ++ identity: _DatabaseIdentity ++ fd: int ++ # One per connection, so a release applied again after an interrupt kept it queued counts once. ++ lease: int ++ ++ ++@dataclass ++class _InodeProbes: ++ leases: set[int] = field(default_factory=set) ++ descriptors: list[int] = field(default_factory=list) ++ idle: list[int] = field(default_factory=list) ++ ++ ++# POSIX locks belong to the process and the inode, so closing any descriptor on the inode drops the locks every other ++# connection of this process holds there. SQLite defers its own closes for this reason (section 2.2 of ++# https://www.sqlite.org/howtocorrupt.html); the probes stay open until the last connection on their inode closes. ++class _ProbeDescriptors: ++ def __init__(self) -> None: ++ self._lock = threading.Lock() ++ self._inodes: dict[_DatabaseIdentity, _InodeProbes] = {} ++ self._unclaimed: list[int] = [] ++ self._releases: deque[_Probe] = deque() ++ self._leases = count() ++ ++ def adopt(self, database: str, claimed: list[_Probe]) -> _Probe | None: ++ try: ++ status = os.lstat(database) ++ except OSError: ++ return None ++ identity = status.st_dev, status.st_ino ++ with self._lock: ++ probes = self._inodes.get(identity) ++ if not stat.S_ISREG(status.st_mode) or probes is None or not probes.idle: ++ return None ++ claimed.append(adopted := _Probe(identity, probes.idle.pop(), next(self._leases))) ++ probes.leases.add(adopted.lease) ++ return adopted ++ ++ def retain(self, fd: int, identity: _DatabaseIdentity, claimed: list[_Probe]) -> _Probe: ++ with self._lock: ++ probes = self._inodes.setdefault(identity, _InodeProbes()) ++ probes.descriptors.append(fd) ++ claimed.append(retained := _Probe(identity, fd, next(self._leases))) ++ probes.leases.add(retained.lease) ++ return retained ++ ++ def close_unclaimed(self, fd: int) -> None: ++ # If fstat failed, the inode is unknown. Closing it beside a live SQLite connection could release that ++ # connection's POSIX locks, so defer the close until no tracked connection remains. ++ with self._lock: ++ if self._inodes: ++ self._unclaimed.append(fd) ++ else: ++ with suppress(OSError): ++ os.close(fd) ++ ++ def release(self, probe: _Probe) -> None: ++ # A finalizer may run this on a thread already inside the lock, so it never waits; the lock holder drains. ++ self._releases.append(probe) ++ self.drain() ++ ++ def drain(self) -> None: ++ while self._releases and self._lock.acquire(blocking=False): ++ try: ++ while self._releases: ++ self._release_locked(self._releases[0]) ++ self._releases.popleft() ++ finally: ++ self._lock.release() ++ ++ def _release_locked(self, probe: _Probe) -> None: ++ if (probes := self._inodes.get(probe.identity)) is None: ++ return ++ if probe.lease in probes.leases: ++ probes.leases.remove(probe.lease) ++ if probes.leases: ++ probes.idle.append(probe.fd) ++ if probes.leases: ++ return ++ probes.idle.clear() ++ # Closing under the lock keeps a concurrent connect from taking a lock on this inode before the close. ++ while probes.descriptors: ++ fd = probes.descriptors.pop() ++ with suppress(OSError): ++ os.close(fd) ++ del self._inodes[probe.identity] ++ if not self._inodes: ++ while self._unclaimed: ++ fd = self._unclaimed.pop() ++ with suppress(OSError): ++ os.close(fd) ++ ++ ++_PROBE_DESCRIPTORS = _ProbeDescriptors() ++ ++ ++class _Connection(sqlite3.Connection): ++ _probe: _Probe | None = None ++ database_identity: _DatabaseIdentity | None = None ++ ++ def close(self) -> None: ++ try: ++ super().close() ++ finally: ++ if (probe := self._probe) is not None: ++ self._probe = None ++ _PROBE_DESCRIPTORS.release(probe) ++ ++ def __del__(self) -> None: ++ with suppress(Exception): ++ self.close() ++ ++ ++def _open_lock_database(database: str) -> tuple[int, _DatabaseIdentity]: ++ fd = os.open(database, _DB_OPEN_FLAGS, 0o600) ++ try: ++ status = os.fstat(fd) ++ except BaseException: ++ _PROBE_DESCRIPTORS.close_unclaimed(fd) ++ raise ++ if not stat.S_ISREG(status.st_mode): ++ os.close(fd) ++ msg = f"refusing a non-regular lock database: {database!r}" ++ raise OSError(msg) ++ return fd, (status.st_dev, status.st_ino) ++ ++ ++def connect(database: str) -> sqlite3.Connection: ++ if sys.platform == "win32": ++ # Windows handle hardening is not part of the POSIX recipe backport. ++ return sqlite3.connect(database, check_same_thread=False) ++ claimed: list[_Probe] = [] ++ try: ++ if (probe := _PROBE_DESCRIPTORS.adopt(database, claimed)) is None: ++ fd, identity = _open_lock_database(database) ++ probe = _PROBE_DESCRIPTORS.retain(fd, identity, claimed) ++ _PROBE_DESCRIPTORS.drain() ++ connection = sqlite3.connect(f"{_FD_DIR}/{probe.fd}", check_same_thread=False, factory=_Connection) ++ connection._probe = probe ++ connection.database_identity = probe.identity ++ claimed.clear() ++ return connection ++ finally: ++ for probe in claimed: ++ _PROBE_DESCRIPTORS.release(probe) +diff --git a/tests/test_read_write_security.py b/tests/test_read_write_security.py +new file mode 100644 +index 0000000..e2b862a +--- /dev/null ++++ b/tests/test_read_write_security.py +@@ -0,0 +1,120 @@ ++from __future__ import annotations ++ ++import errno ++import os ++import subprocess ++import sys ++from pathlib import Path ++from collections.abc import Generator ++ ++import pytest ++ ++pytest.importorskip("sqlite3") ++ ++import sqlite3 ++ ++from filelock import ReadWriteLock ++from filelock import _rw_security ++from filelock._read_write import _cleanup_connections ++ ++pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX read-write lock security backport") ++ ++ ++@pytest.fixture(autouse=True) ++def _close_read_write_connections() -> Generator[None]: ++ _cleanup_connections() ++ ReadWriteLock._instances.clear() ++ yield ++ _cleanup_connections() ++ ReadWriteLock._instances.clear() ++ ++ ++def test_read_write_lock_refuses_a_symlinked_path(tmp_path: Path) -> None: ++ target = tmp_path / "target.db" ++ target.write_bytes(b"unchanged") ++ database = tmp_path / "lock.db" ++ database.symlink_to(target) ++ with pytest.raises(OSError): ++ ReadWriteLock(database, is_singleton=False) ++ assert target.read_bytes() == b"unchanged" ++ ++ ++def test_read_write_lock_refuses_a_non_regular_path(tmp_path: Path) -> None: ++ database = tmp_path / "lock.db" ++ os.mkfifo(database) ++ with pytest.raises(OSError, match="non-regular"): ++ ReadWriteLock(database, is_singleton=False) ++ ++ ++def test_closing_a_peer_keeps_another_connections_lock(tmp_path: Path) -> None: ++ database = tmp_path / "lock.db" ++ holder = ReadWriteLock(database, is_singleton=False) ++ peer = ReadWriteLock(database, is_singleton=False) ++ try: ++ holder.acquire_write() ++ peer.close() ++ result = subprocess.run( ++ [sys.executable, "-c", "import sqlite3,sys; c=sqlite3.connect(sys.argv[1], timeout=0); c.execute('BEGIN EXCLUSIVE')", str(database)], ++ capture_output=True, ++ text=True, ++ check=False, ++ ) ++ assert result.returncode != 0 ++ assert "database is locked" in result.stderr ++ finally: ++ holder.close() ++ ++ ++def _fail_opened_database_fstat(database: Path, monkeypatch: pytest.MonkeyPatch) -> list[int]: ++ opened: list[int] = [] ++ real_open = os.open ++ real_fstat = os.fstat ++ def open_database(path: str | os.PathLike[str], flags: int, mode: int = 0o777, **kwargs: object) -> int: ++ fd = real_open(path, flags, mode, **kwargs) ++ if os.fspath(path) == os.fspath(database): ++ opened.append(fd) ++ return fd ++ def fail_fstat(fd: int) -> os.stat_result: ++ if fd in opened: ++ raise OSError(errno.EIO, "database fstat failed") ++ return real_fstat(fd) ++ monkeypatch.setattr(os, "open", open_database) ++ monkeypatch.setattr(os, "fstat", fail_fstat) ++ return opened ++ ++ ++def test_failed_database_fstat_closes_an_unowned_descriptor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: ++ database = tmp_path / "lock.db" ++ with monkeypatch.context() as patch: ++ opened = _fail_opened_database_fstat(database, patch) ++ with pytest.raises(OSError, match="database fstat failed"): ++ ReadWriteLock(database, is_singleton=False) ++ assert len(opened) == 1 ++ with pytest.raises(OSError) as caught: ++ os.fstat(opened[0]) ++ assert caught.value.errno == errno.EBADF ++ ++ ++def test_failed_database_fstat_preserves_a_peer_lock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: ++ database = tmp_path / "lock.db" ++ holder = ReadWriteLock(database, is_singleton=False) ++ try: ++ holder.acquire_write() ++ with monkeypatch.context() as patch: ++ opened = _fail_opened_database_fstat(database, patch) ++ with pytest.raises(OSError, match="database fstat failed"): ++ ReadWriteLock(database, is_singleton=False) ++ assert len(opened) == 1 ++ # The unidentified descriptor must remain open while closing it could drop the holder's POSIX locks. ++ os.fstat(opened[0]) ++ result = subprocess.run( ++ [sys.executable, "-c", "import sqlite3,sys; c=sqlite3.connect(sys.argv[1], timeout=0); c.execute('BEGIN EXCLUSIVE')", str(database)], ++ capture_output=True, text=True, check=False, ++ ) ++ assert result.returncode != 0 ++ assert "database is locked" in result.stderr ++ finally: ++ holder.close() ++ with pytest.raises(OSError) as caught: ++ os.fstat(opened[0]) ++ assert caught.value.errno == errno.EBADF diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb index a4bd002f1f..db7f9187de 100644 --- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb +++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb @@ -14,6 +14,7 @@ SRC_URI += " file://run-ptest \ file://CVE-2026-112935_p3.patch \ file://CVE-2026-112909_p1.patch \ file://CVE-2026-112909_p2.patch \ + file://CVE-2026-112936.patch \ " SRC_URI[sha256sum] = "b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694" @@ -33,6 +34,7 @@ RDEPENDS:${PN} += " \ " RDEPENDS:${PN}-ptest += " \ + python3-sqlite3 \ python3-pytest-asyncio \ python3-pytest-mock \ python3-virtualenv \ From patchwork Wed Nov 11 05:29:34 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" X-Patchwork-Id: 103663 Received: from rcdn-iport-4.cisco.com (rcdn-iport-4.cisco.com [173.37.86.75]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.3364.1794374983916368146 for ; Tue, 10 Nov 2026 21:29:44 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=hm6S6nlU; spf=pass (domain: cisco.com, ip: 173.37.86.75, mailfrom: dkelaiya@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=15187; q=dns/txt; s=iport01; t=1794374984; x=1795584584; h=from:to:cc:subject:date:message-id:in-reply-to: references:mime-version:content-transfer-encoding; bh=fDJukE3FBwP4GfwfI45J8y5RekQ3PP8r42m6Qr/bRXo=; b=hm6S6nlUfKb2wgnC57UZHC7Xcqr62LNVacrlCimy2dOkyxlMmzcKu1iV qfylgpbuB5HiMh7PPqY8/t7mjQrF2b+cjJrNtS77gDUBxKDZIs1PJku9X czkSlNUJe1j1kiY4HQAB4nHNSD4CjI/dw3DOpXB/Of5GoYFeMNOHv/8GO YrFgdAZqYlLVU1322WdyszTRqfBLqNpRN6Ncp0jmLsiHYDoVcHrn4AXSp LldKEs0vFSu3leAktCRK/lyusuYo4Nxop0kfYBk2Om717qfPoNofjxURp PxzaugscstDrDsz13VK5cTBjeR9/7AGjbTmcNc9Mu5HVSJu1XSfs0nteB w==; X-CSE-ConnectionGUID: mG1Jr12xQ6WjqY2BgXstUQ== X-CSE-MsgGUID: x+NliCO3SKKYgFkwmWjvzQ== X-IPAS-Result: A0BQAgDN/PNq/4z/Ja1RAQiCWYJXdWBDSQOEVJF0A4tlhWeMVoF+DwEBAQ9EDQQBAYQ/RgKOHgImNAkOAQIEAwIDAQEBAQEBAQEBAQEBCgEBBQEBAQIBBwWBDhOGTw2QEgECAQMjBAsBGAEtEBwDAQIDAiYCAiALHQEFCBAJgwIBgjoDNwMRBrlYen8zgQGDKQGBVNhMDYJaAQsUAYEKLoVCgwAgAYUDXRgBRIQ5JxsbgXKBFYNpgQWBGkICAoEyBAGEA4JIIgSCDRV6EoFagQIHghuDGAOGWwKBT4clSIECHANZLAFVEw0KCwcFgWgDNRIqFW4yHYEjPhcvWBsGBYEcgR6BEoEHXYErKF0BIYEIgXYCglGCAAIBSUEOB0VTCSVDEkckIggSCQETGiwKgRo1PAkmPxUMZwsaBWEIAg0MdDkQMB4kLwUaJj4bCn4YDUgRLDcVGQQ9AW4Hkm6CdR8BFkMiEwEHHwMCFRolMQxbGScPEykDkmcCkB6CIaAgcQoog3iMJI8+hXwaM4QElBqSVAuYfY4KhAqRXxtNhGqBaDxGgRNwFTuCZwlKGQ+OKw4Lg2CFE8lIJjICCQMvAQEHAgcOAwuBaJABgX0BAQ IronPort-Data: A9a23:j2hACa68we1ivmt/kZQKAwxRtGjGchMFZxGqfqrLsTDasY5as4F+v msbCm/VOf6IYGumfY10bInl9RgO75KEztJjGQBsqHs8Zn8b8sCt6fZ1gavT04J+CuWZESqLO u1HMoGowPgcFyGa+1H1dOex9RGQ7InQLpLkEunIJyttcgFtTSYlmHpLlvUw6mJSqYDR7zil5 5Wo/KUzBHf/g2QqaztNsvrawP9SlK2aVA0w7wRWic9j5Dcyp1FNZLoDKKe4KWfPQ4U8NoaSW +bZwbilyXjS9hErB8nNuu6TnpoiG+O60aCm0xK6aoD66vRwjnVaPpUTaJLwXXxqZwChxLid/ jniWauYEm/FNoWU8AgUvoIx/ytWZcWq85efSZSzXFD6I0DuKxPRL/tS4E4eOKJB2PwuW3F3q 8MaMQFXRB+znP6L+efuIgVsrpxLwMjDJogTvDRkiDreF/tjGcuFSKTR7tge1zA17ixMNa+BP IxCNnw1MUmGOkEXUrsUIMpWcOOAinrydzRZuVu9rqss6G+Vxwt0uFToGIeJJIHQFJ0Oxi50o ErWvEL9Azo9b+e86guK3HGzg9DNgzHCDdd6+LqQs6QCbEeo7msLBRsbUFG2rfW0hguyVsxSL 2QQ+zEytu417EGtQ9z3UhG0rXLCuQQTM+e8CMUg4w2Lj66R6AGDCy1cFXhKaccts4k9QjlCO kK1ou4FzAdH6NW9IU9xPJ/Oxd9uEUD59VM/WBI= IronPort-HdrOrdr: A9a23:dIs/GK0tMdEFHbD0jX5cUgqjBGokLtp133Aq2lEZdPWaSKOlfq eV7ZImPH7P+VEssR4b+OxoVJPsfZqYz+8W3WBzB8bHYOCZgguVxehZhOOIqQEIWReOk9K1vp 0PT0ERMrHN5CBB/KXHyTj9Nco8y9+a963tr+Lfw3BxCTxOUchbnn5E4sLxKDwMeOGAbqBJbK ah2g== X-Talos-CUID: 9a23:XeNxhW8WoCNSNAix3lmVv0s7Q+I/KyfN9SnvIVaJU0xmeKaJdGbFrQ== X-Talos-MUID: 9a23:wrciWwpCnUZXQ2l7UYAezyEhEsNpyIP1M2MuoJoouNaBCi5uKQ7I2Q== X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,207,1787011200"; d="scan'208";a="546529030" Received: from rcdn-l-core-03.cisco.com ([173.37.255.140]) by rcdn-iport-4.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 11 Nov 2026 05:29:43 +0000 Received: from sjc-ads-5675.cisco.com (sjc-ads-5675.cisco.com [10.28.88.189]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by rcdn-l-core-03.cisco.com (Postfix) with ESMTPS id 4FD96180001C8; Wed, 11 Nov 2026 05:29:43 +0000 (GMT) Received: by sjc-ads-5675.cisco.com (Postfix, from userid 1887444) id B6740CBF21E; Tue, 10 Nov 2026 21:29:42 -0800 (PST) From: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" To: openembedded-devel@lists.openembedded.org Cc: Darsh Kelaiya Subject: [oe][meta-python][wrynose][PATCH 5/9] python3-filelock: fix CVE-2026-112915 Date: Tue, 10 Nov 2026 21:29:34 -0800 Message-Id: <20261111052938.3691463-6-dkelaiya@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261111052938.3691463-1-dkelaiya@cisco.com> References: <20261111052938.3691463-1-dkelaiya@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: ANONYMOUS;sjc-ads-5675.cisco.com [10.28.88.189];TLSv1.3;TLS_AES_256_GCM_SHA384;256 X-Outbound-SMTP-Client: 10.28.88.189, sjc-ads-5675.cisco.com X-Outbound-Node: rcdn-l-core-03.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Wed, 11 Nov 2026 05:29:50 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/132117 From: Darsh Kelaiya Backport the schema-work guards from [1], as identified by advisory [2]. Bound SQLite schema size and query work before acquisition so a crafted lock database cannot cause unbounded schema processing. Retain 3.25.2's persistent connections and timeout behavior. Use the SQLite limits available in Wrynose's Python 3.14, disable statement caching for guarded work and retain both upstream malformed-schema regressions. The shared-path changes follow in the CVE-2026-112940 fix. [1] https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d [2] https://github.com/tox-dev/filelock/security/advisories/GHSA-cwqr-hh33-gh44 Signed-off-by: Darsh Kelaiya --- .../python3-filelock/CVE-2026-112915.patch | 280 ++++++++++++++++++ .../python/python3-filelock_3.25.2.bb | 1 + 2 files changed, 281 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112915.patch diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112915.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112915.patch new file mode 100644 index 0000000000..47aa3a3aba --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112915.patch @@ -0,0 +1,280 @@ +From 5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 03:39:07 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(read-write):=20keep=20SQLite?= + =?UTF-8?q?=20off=20shared=20names,=20bound=20schema=20(#834)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-112915 +Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d] + +Backport Changes: +- Split the schema-loading guard from the shared-path changes, + which are applied separately for CVE-2026-112940. +- Place the guard in _rw_security.py and wrap the existing 3.25.2 + configure/begin path instead of newer operation-context classes. +- Keep the per-instance connection and existing timeout semantics. +- Disable statement caching so guarded statements are prepared + within the schema budget. +- Use sqlite3.Connection.setlimit supplied by Wrynose Python 3.14; + this ReadWriteLock backport requires Python 3.11 or newer. +- Retain both upstream malformed-schema fixtures in the dedicated + regression file and explicitly close persistent connections. +- Omit unrelated path, Windows, fork and coverage-task changes + from this split patch; applicable path changes follow separately. + +Signed-off-by: Darsh Kelaiya +--- + src/filelock/_read_write.py | 45 ++++++------- + src/filelock/_rw_security.py | 34 ++++++++-- + tests/test_read_write_security.py | 101 ++++++++++++++++++++++++++++++ + 3 files changed, 154 insertions(+), 26 deletions(-) + +diff --git a/src/filelock/_read_write.py b/src/filelock/_read_write.py +index e8b6420..66a4664 100644 +--- a/src/filelock/_read_write.py ++++ b/src/filelock/_read_write.py +@@ -13,7 +13,7 @@ from weakref import WeakValueDictionary + + from ._api import AcquireReturnProxy + from ._error import Timeout +-from ._rw_security import canonical, connect ++from ._rw_security import bounded_schema, canonical, connect + + if TYPE_CHECKING: + from collections.abc import Generator +@@ -189,27 +189,28 @@ class ReadWriteLock(metaclass=_ReadWriteLockMeta): + def _configure_and_begin( + self, mode: Literal["read", "write"], timeout: float, *, blocking: bool, start_time: float + ) -> None: +- waited = time.perf_counter() - start_time +- timeout_ms = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited) +- self._con.execute(f"PRAGMA busy_timeout={timeout_ms};").close() +- # Use legacy journal mode (not WAL) because WAL does not block readers when a concurrent EXCLUSIVE +- # write transaction is active, making read-write locking impossible without modifying table data. +- # MEMORY is safe here since no actual writes happen — crashes cannot corrupt the DB. +- # See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions +- # +- # Set here (not in __init__) because this pragma itself may block on a locked database, +- # so it must run after busy_timeout is configured above. +- self._con.execute("PRAGMA journal_mode=MEMORY;").close() +- # Recompute remaining timeout after the potentially blocking journal_mode pragma. +- waited = time.perf_counter() - start_time +- if (recomputed := timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)) != timeout_ms: +- self._con.execute(f"PRAGMA busy_timeout={recomputed};").close() +- stmt = "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;" +- self._con.execute(stmt).close() +- if mode == "read": +- # A SELECT is needed to force SQLite to actually acquire the SHARED lock on the database. +- # https://www.sqlite.org/lockingv3.html#transaction_control +- self._con.execute("SELECT name FROM sqlite_schema LIMIT 1;").close() ++ with bounded_schema(self._con, self.lock_file): ++ waited = time.perf_counter() - start_time ++ timeout_ms = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited) ++ self._con.execute(f"PRAGMA busy_timeout={timeout_ms};").close() ++ # Use legacy journal mode (not WAL) because WAL does not block readers when a concurrent EXCLUSIVE ++ # write transaction is active, making read-write locking impossible without modifying table data. ++ # MEMORY is safe here since no actual writes happen — crashes cannot corrupt the DB. ++ # See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions ++ # ++ # Set here (not in __init__) because this pragma itself may block on a locked database, ++ # so it must run after busy_timeout is configured above. ++ self._con.execute("PRAGMA journal_mode=MEMORY;").close() ++ # Recompute remaining timeout after the potentially blocking journal_mode pragma. ++ waited = time.perf_counter() - start_time ++ if (recomputed := timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)) != timeout_ms: ++ self._con.execute(f"PRAGMA busy_timeout={recomputed};").close() ++ stmt = "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;" ++ self._con.execute(stmt).close() ++ if mode == "read": ++ # A SELECT is needed to force SQLite to actually acquire the SHARED lock on the database. ++ # https://www.sqlite.org/lockingv3.html#transaction_control ++ self._con.execute("SELECT name FROM sqlite_schema LIMIT 1;").close() + + def _acquire(self, mode: Literal["read", "write"], timeout: float, *, blocking: bool) -> AcquireReturnProxy: + opposite = "write" if mode == "read" else "read" +diff --git a/src/filelock/_rw_security.py b/src/filelock/_rw_security.py +index 6916212..9e4f92f 100644 +--- a/src/filelock/_rw_security.py ++++ b/src/filelock/_rw_security.py +@@ -7,10 +7,13 @@ import stat + import sys + import threading + from collections import deque +-from contextlib import suppress ++from contextlib import contextmanager, suppress + from dataclasses import dataclass, field + from itertools import count +-from typing import NamedTuple ++from typing import TYPE_CHECKING, NamedTuple ++ ++if TYPE_CHECKING: ++ from collections.abc import Generator + + _DatabaseIdentity = tuple[int, int] + _DB_OPEN_FLAGS = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) +@@ -154,14 +157,14 @@ def _open_lock_database(database: str) -> tuple[int, _DatabaseIdentity]: + def connect(database: str) -> sqlite3.Connection: + if sys.platform == "win32": + # Windows handle hardening is not part of the POSIX recipe backport. +- return sqlite3.connect(database, check_same_thread=False) ++ return sqlite3.connect(database, check_same_thread=False, cached_statements=0) + claimed: list[_Probe] = [] + try: + if (probe := _PROBE_DESCRIPTORS.adopt(database, claimed)) is None: + fd, identity = _open_lock_database(database) + probe = _PROBE_DESCRIPTORS.retain(fd, identity, claimed) + _PROBE_DESCRIPTORS.drain() +- connection = sqlite3.connect(f"{_FD_DIR}/{probe.fd}", check_same_thread=False, factory=_Connection) ++ connection = sqlite3.connect(f"{_FD_DIR}/{probe.fd}", check_same_thread=False, factory=_Connection, cached_statements=0) + connection._probe = probe + connection.database_identity = probe.identity + claimed.clear() +@@ -169,3 +172,26 @@ def connect(database: str) -> sqlite3.Connection: + finally: + for probe in claimed: + _PROBE_DESCRIPTORS.release(probe) ++ ++ ++@contextmanager ++def bounded_schema(connection: sqlite3.Connection, database: str) -> Generator[None]: ++ # The first statement loads the file's complete schema, which no timeout bounds, and a lock database has none. ++ # Waiting for a lock runs no virtual machine steps, so a step budget bounds only that load. ++ calls = count() ++ max_calls = 10 ++ connection.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, 4096) ++ connection.set_progress_handler(lambda: next(calls) >= max_calls, 1000) ++ try: ++ yield ++ except sqlite3.DatabaseError as error: ++ # An interrupted schema load reports a malformed schema rather than the interrupt. ++ if isinstance(error, sqlite3.DataError) or next(calls) > max_calls: ++ msg = ( ++ f"refusing a lock database whose schema is too large to load: {database!r}; " ++ "give ReadWriteLock a file of its own" ++ ) ++ raise ValueError(msg) from error ++ raise ++ finally: ++ connection.set_progress_handler(None, 0) +diff --git a/tests/test_read_write_security.py b/tests/test_read_write_security.py +index e2b862a..a995c80 100644 +--- a/tests/test_read_write_security.py ++++ b/tests/test_read_write_security.py +@@ -2,6 +2,8 @@ from __future__ import annotations + + import errno + import os ++import struct ++from typing import Literal + import subprocess + import sys + from pathlib import Path +@@ -65,6 +67,105 @@ def test_closing_a_peer_keeps_another_connections_lock(tmp_path: Path) -> None: + holder.close() + + ++_PAGE_SIZE = 4096 ++ ++ ++_TABLE_LEAF_PAGE = 0x0D ++ ++ ++@pytest.mark.timeout(60) ++@pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) ++def test_read_write_lock_refuses_a_schema_that_never_finishes_loading( ++ tmp_path: Path, mode: Literal["read", "write"] ++) -> None: ++ # Every interior page leads to the next one through each of its 660 cells, so the schema has 661**4 rows to scan. ++ database = tmp_path / "lock.db" ++ pages = [ ++ _btree_page( ++ 0x05, # table interior page ++ struct.pack(">IB", number + 1, 1), ++ 660, ++ right_child=struct.pack(">I", number + 1), ++ header_at=100 if number == 1 else 0, ++ ) ++ for number in range(1, 5) ++ ] ++ pages.append(_btree_page(_TABLE_LEAF_PAGE, bytes([6, 1, 6, 0, 0, 0, 0, 0]), 1)) ++ pages[0][:100] = _file_header(len(pages)) ++ database.write_bytes(b"".join(pages)) ++ lock = ReadWriteLock(database, is_singleton=False) ++ ++ try: ++ with pytest.raises(ValueError, match="schema"): ++ (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=1) ++ finally: ++ lock.close() ++ ++ ++@pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) ++def test_read_write_lock_refuses_a_schema_row_longer_than_a_lock_needs( ++ tmp_path: Path, mode: Literal["read", "write"] ++) -> None: ++ # The row's text continues on an overflow page that names itself as the next one, so one page of disk reads as a ++ # megabyte, far more schema text than a lock database holds and below SQLite's one-billion-byte string limit. ++ long_sql_length = 1_000_000 ++ database = tmp_path / "lock.db" ++ statement = b"CREATE TABLE application (value) --" ++ columns = [b"table", b"application", b"application", b"\x02"] ++ serial_types = [13 + 2 * len(column) for column in columns[:3]] + [1, 13 + 2 * long_sql_length] ++ record_header = b"".join(_varint(serial_type) for serial_type in serial_types) ++ record_start = _varint(len(record_header) + 1) + record_header + b"".join(columns) + statement ++ record_length = len(record_start) + long_sql_length - len(statement) ++ # How much of an overflowing record a table leaf keeps on its own page. ++ local = (minimum := (_PAGE_SIZE - 12) * 32 // 255 - 23) + (record_length - minimum) % (_PAGE_SIZE - 4) ++ cell = _varint(record_length) + _varint(1) + record_start.ljust(local, b"-") + struct.pack(">I", 2) ++ leaf = _btree_page(_TABLE_LEAF_PAGE, cell, 1, header_at=100) ++ page_count = long_sql_length // _PAGE_SIZE + 2 ++ leaf[:100] = _file_header(page_count) ++ database.write_bytes(leaf + struct.pack(">I", 2).ljust(_PAGE_SIZE, b"-")) ++ # SQLite refuses a record longer than the file, which a hole stretches at no cost. ++ os.truncate(database, page_count * _PAGE_SIZE) ++ lock = ReadWriteLock(database, is_singleton=False) ++ ++ try: ++ with pytest.raises(ValueError, match="schema"): ++ (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=1) ++ finally: ++ lock.close() ++ ++ ++def _btree_page(kind: int, cell: bytes, cell_count: int, *, right_child: bytes = b"", header_at: int = 0) -> bytearray: ++ page = bytearray(_PAGE_SIZE) ++ cell_at = _PAGE_SIZE - len(cell) ++ page[cell_at:] = cell ++ # Every slot of the cell pointer array names the one cell. ++ header = struct.pack(">BHHHB", kind, 0, cell_count, cell_at, 0) + right_child ++ pointers = struct.pack(">H", cell_at) * cell_count ++ page[header_at : header_at + len(header) + len(pointers)] = header + pointers ++ return page ++ ++ ++def _file_header(page_count: int) -> bytes: ++ # Rollback journal format, a matching change counter and version-valid-for, so SQLite trusts the page count. ++ return struct.pack( ++ ">16sH6B12I20s2I", ++ b"SQLite format 3\0", ++ _PAGE_SIZE, ++ *(1, 1, 0, 64, 32, 32), ++ *(1, page_count, 0, 0, 1, 4, 0, 0, 1, 0, 0, 0), ++ b"", ++ 1, ++ 3040001, ++ ) ++ ++ ++def _varint(value: int) -> bytes: ++ groups = [value & 0x7F] ++ while value := value >> 7: ++ groups.insert(0, value & 0x7F | 0x80) ++ return bytes(groups) ++ ++ + def _fail_opened_database_fstat(database: Path, monkeypatch: pytest.MonkeyPatch) -> list[int]: + opened: list[int] = [] + real_open = os.open diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb index db7f9187de..4f059e438e 100644 --- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb +++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb @@ -15,6 +15,7 @@ SRC_URI += " file://run-ptest \ file://CVE-2026-112909_p1.patch \ file://CVE-2026-112909_p2.patch \ file://CVE-2026-112936.patch \ + file://CVE-2026-112915.patch \ " SRC_URI[sha256sum] = "b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694" From patchwork Wed Nov 11 05:29:35 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" X-Patchwork-Id: 103667 Received: from rcdn-iport-1.cisco.com (rcdn-iport-1.cisco.com [173.37.86.72]) by mx.groups.io with SMTP id smtpd.msgproc02-g2.3283.1794374983856874227 for ; Tue, 10 Nov 2026 21:29:45 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=dQo+jbP2; spf=pass (domain: cisco.com, ip: 173.37.86.72, mailfrom: dkelaiya@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=37212; q=dns/txt; s=iport01; t=1794374985; x=1795584585; h=from:to:cc:subject:date:message-id:in-reply-to: references:mime-version:content-transfer-encoding; bh=z1jqVoJRIdFrep/lZnaWYt2/++hnpN/18Itf+miG2gU=; b=dQo+jbP2IJg4buc3/iHfOA9ABW+Fqq65AyvTEgg2eGFe4SFOimcMKODf iqQE0vV7/q8/DSoIEsPFOM2ZPLGwee7jVA9NzJqLHwNVd+VQAyYpmNj/+ rZ2rfOJ/UvbfTI+m/fipAEI7XQ0lUEhqNLYUaM82BOgbeC7aHgiJGA04D BxRGhCXB3nZLK3p8GO7ii9/dT7Nzgg0I0cK8u3LpXac6FKwGfR2JGIUHv GeB/2pPoedroBU1QWXdg5SFhDuEaJm2ktXlEM2STbBjlKWuPQDXLemBqI mgYSHQnZXgd3m2T/fFllXaqPi8pdEnXGdAEXhLcqL/j1HNoDBZyMBKdWh g==; X-CSE-ConnectionGUID: fsjoo6iyQy2VoYTm3RNywQ== X-CSE-MsgGUID: 5xyuU/CvTMKwkBE99v/w5A== X-IPAS-Result: A0AnAABQ/PNq/4v/Ja1RAQgdAQEBAQkBEgEFBQGBfAgBCwGCVnVgQ0kDhFSIG4lZA4ETilKFZ4xWFIFqDwEBAQ9EDQQBAYQ/RgKOHgImNAkOAQIEAwIDAQEBAQEBAQEBAQEBCgEBBQEBAQIBBwWBDhOGTw2QEgECAQMaAQgECwEYARsSEBwDAQIDAhEVAgIgCx0BBQgQCYMCAYI6AzcDEQa5Unp/M4EBgykBgVTYTA2CWgELFAGBCi4BhUGDACABhQNdGAFEhDknGxuBchd+gnN2gQWBGkICAQEBgRYODQQBDliDHYJIIgSCDRV6EoFaHmQHAoIZgxgDhlsCgU+HJUiBAhwDWSwBVRMNCgsHBYFoAzUSKhVuMh2BIz4XL1gbBgWBHIEegRKBB12BKyhdASGBCIF2AoJRggACAUlBDgdFUwklQxJHJCIIEgkBExosCoEaNTwJJj8VDGcLGgVhCAINDHQ5EDAeJC8FGiY+Gwp+GA1IESw3FRkEPQFuB5JughE3LSABLBA+CQoBGwoBAwIVOwQCOwcBAQYtMAcHAhMMDwILLwOSZwKQHoIhjACUIHEKKIN4jCSPPoV8GjOEBIFXkkOSVAuYfY1cLoQKkSsuBhsDSoRqgWg8RoETcBU7gmcJShkPjisDCwuDYIRQQ8lIJjICCQMvAQEHAgcOAwuBaJAAAQMkdmABAQ IronPort-Data: A9a23:UzUn76hBF7kJet3lnp74j2jpX161MBEKZh0ujC45NGQN5FlHY01je htvUDrUafePa2T2Ktx/boS2oU8Du5GAx9ZrTgo6qSE9HiljpJueD7x1DKtf0wB+jyHnZBg6h ynLQoCYdKjYdleF+FHwdOCn8ikkvU2xbuKUIPbePSxsThNTRi4kiBZy88Y0mYcAbeKRW2thg vus5ZeAULOZ82QsaDxNsvva8EoHUMna4Vv0gHRvPZing3eG/5UlJMp3Db28KXL+Xr5VEoaSL 87fzKu093/u5BwkDNWoiN7TKiXmlZaLYGBiIlIPM0STqkAqSh4ai87XB9JAAatjsAhlqvgqo Dl7WTNcfi9yVkHEsLx1vxC1iEiSN4UekFPMCSDXXcB+UyQqflO0q8iCAn3aMqUW4eRwBj9P5 MUJcg4KXyyG3NiQ0a2CH7wEasQLdKEHPasFsX1miDWcBvE8TNWbGePB5MRT23E7gcUm8fT2P pVCL2EwKk6dPlsWYQZ/5JEWxI9EglHzcDBcoVOErII84nPYy0p6172F3N/9J4DUHp0KwBbHz o7A1znpEwwnc/eB9R+m7XGGq76eg2TkBatHQdVU8dYv2jV/3Fc7DwUbU1a+q/S1hkOyHt5SN UEQ0i4vtrQpskuzQ9/wWhe1rHKJslgbQdU4LgEhwBuGxqyR50OSAXIJC2YbLtcnr8QxAzct0 zdlgu/UONCmi5XNIVr1y1tehWna1fQ9RYPaWRI5cA== IronPort-HdrOrdr: A9a23:B2aCI6hGSwQAqbsJ29Y6svoqDXBQXgIji2hC6mlwRA09TyVXra +TdZMgpHnJYVcqKRYdcL+7WZVoLUmwyXcx2/hyAV7AZniDhILXFuFfBOLZqlWKcREWtNQtsJ uIGJIRNDSfNzRHZL7BkWqFOudl5sWb+6a1guqb5XJsQQZ2L5xE1W5Ce3+m+okcfng8OXL/f6 DsnvZ6mw== X-Talos-CUID: 9a23:7w6d62A8dPVkiQv6Eyd89kkTRME0S2yH8nDhH0rjM0B4V4TAHA== X-Talos-MUID: 9a23:a0mekQtjRnGGCIdTsM2n2SFzKfovyLqSLWMSkr8r6piacjMtEmLI X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,207,1787011200"; d="scan'208";a="546307687" Received: from rcdn-l-core-02.cisco.com ([173.37.255.139]) by rcdn-iport-1.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 11 Nov 2026 05:29:44 +0000 Received: from sjc-ads-5675.cisco.com (sjc-ads-5675.cisco.com [10.28.88.189]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by rcdn-l-core-02.cisco.com (Postfix) with ESMTPS id 619E31800020D; Wed, 11 Nov 2026 05:29:43 +0000 (GMT) Received: by sjc-ads-5675.cisco.com (Postfix, from userid 1887444) id BA6F5CBF234; Tue, 10 Nov 2026 21:29:42 -0800 (PST) From: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" To: openembedded-devel@lists.openembedded.org Cc: Darsh Kelaiya Subject: [oe][meta-python][wrynose][PATCH 6/9] python3-filelock: fix CVE-2026-112940 Date: Tue, 10 Nov 2026 21:29:35 -0800 Message-Id: <20261111052938.3691463-7-dkelaiya@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261111052938.3691463-1-dkelaiya@cisco.com> References: <20261111052938.3691463-1-dkelaiya@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: ANONYMOUS;sjc-ads-5675.cisco.com [10.28.88.189];TLSv1.3;TLS_AES_256_GCM_SHA384;256 X-Outbound-SMTP-Client: 10.28.88.189, sjc-ads-5675.cisco.com X-Outbound-Node: rcdn-l-core-02.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Wed, 11 Nov 2026 05:29:51 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/132120 From: Darsh Kelaiya Backport the private SQLite filename changes from [1], following advisory [2]. Use checked private hard links in shared directories so SQLite cannot reopen attacker-controlled database or side-file names. Check database identity before statements and after BEGIN, and keep private aliases until the existing persistent connection closes. Include the WAL-header prerequisite [3] and inherited-ACL correction [4]. Retain the journal, replacement, cleanup and descriptor-lifetime regressions. Add the hashing module used for private-alias ownership. [1] https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d [2] https://github.com/tox-dev/filelock/security/advisories/GHSA-v4x8-3wr5-3wq8 [3] https://github.com/tox-dev/filelock/commit/015d27c10d4bdbaaf94aeafb15ee931a2bd6a019 [4] https://github.com/tox-dev/filelock/commit/21bc250542497ff2d74151cdf7f98069d929465a Signed-off-by: Darsh Kelaiya --- .../python3-filelock/CVE-2026-112940_p1.patch | 699 ++++++++++++++++++ .../python3-filelock/CVE-2026-112940_p2.patch | 104 +++ .../python/python3-filelock_3.25.2.bb | 3 + 3 files changed, 806 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112940_p1.patch create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112940_p2.patch diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112940_p1.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112940_p1.patch new file mode 100644 index 0000000000..fda3195568 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112940_p1.patch @@ -0,0 +1,699 @@ +From 5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 03:39:07 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(read-write):=20keep=20SQLite?= + =?UTF-8?q?=20off=20shared=20names,=20bound=20schema=20(#834)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-112940 +Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d] + +Backport Changes: +- Split the shared-path changes from upstream schema hardening, + already applied separately for CVE-2026-112915. +- Transplant applicable POSIX helpers into _rw_security.py without + importing the newer public, cancellation or fork-safety APIs. +- Retain each private alias until the existing 3.25.2 connection + closes; this release keeps connections across lock releases. +- Check the opened database identity before any statement and + after BEGIN, and roll back if acquisition is refused. +- Include the required WAL-header guard from upstream PR #805. +- Adapt dead-holder cleanup to local PID/start-token/host helpers + because 3.25.2 lacks the newer process-identity module. +- Reset the new descriptor pool after fork without adding newer + fork behavior to the existing public API. +- Keep hard-link and trusted-ancestor constraints in the docstring. +- Consolidate applicable journal, replacement, cleanup, side-file + and probe-lifetime regressions in the dedicated test file. +- Adapt construction timing and explicit close calls to persistent + 3.25.2 connections instead of newer per-acquisition connections. +- Omit Windows ACL/handle code and tests for this POSIX recipe, + together with absent fd-fallback/fork fixtures and task metadata. +- Keep upstream owner-PID bounds and reject overlong decimals + before probing stale private directories. +- Retain database identity in the older mocked-connection tests + and restore/close their original persistent connections. + +Signed-off-by: Darsh Kelaiya +--- + src/filelock/_read_write.py | 59 +++++---- + src/filelock/_rw_security.py | 261 ++++++++++++++++++++++++++++++++++++-- + tests/test_read_write_security.py | 190 ++++++++++++++++++++++++++- + tests/test_read_write_unit.py | 12 +- + 4 files changed, 486 insertions(+), 36 deletions(-) + +diff --git a/src/filelock/_read_write.py b/src/filelock/_read_write.py +index 66a4664..8918fc7 100644 +--- a/src/filelock/_read_write.py ++++ b/src/filelock/_read_write.py +@@ -13,7 +13,7 @@ from weakref import WeakValueDictionary + + from ._api import AcquireReturnProxy + from ._error import Timeout +-from ._rw_security import bounded_schema, canonical, connect ++from ._rw_security import bounded_schema, canonical, connect, raise_if_database_replaced + + if TYPE_CHECKING: + from collections.abc import Generator +@@ -107,7 +107,10 @@ class ReadWriteLock(metaclass=_ReadWriteLockMeta): + acquired them. + + By default, ``is_singleton=True``: calling ``ReadWriteLock(path)`` with the same resolved path returns the same +- instance. The lock file must use a ``.db`` extension (SQLite database). ++ instance. The lock file must use a ``.db`` extension (SQLite database). On POSIX, a database directory writable by ++ other accounts must also be writable by this account and support hard links. The database directory and its ++ ancestors must not be replaceable by another account. Give the lock a file of its own; WAL-mode and oversized-schema ++ databases are refused. Private aliases are retained until :meth:`close`, along with the SQLite connection. + + :param lock_file: path to the SQLite database file used as the lock + :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely +@@ -189,28 +192,36 @@ class ReadWriteLock(metaclass=_ReadWriteLockMeta): + def _configure_and_begin( + self, mode: Literal["read", "write"], timeout: float, *, blocking: bool, start_time: float + ) -> None: +- with bounded_schema(self._con, self.lock_file): +- waited = time.perf_counter() - start_time +- timeout_ms = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited) +- self._con.execute(f"PRAGMA busy_timeout={timeout_ms};").close() +- # Use legacy journal mode (not WAL) because WAL does not block readers when a concurrent EXCLUSIVE +- # write transaction is active, making read-write locking impossible without modifying table data. +- # MEMORY is safe here since no actual writes happen — crashes cannot corrupt the DB. +- # See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions +- # +- # Set here (not in __init__) because this pragma itself may block on a locked database, +- # so it must run after busy_timeout is configured above. +- self._con.execute("PRAGMA journal_mode=MEMORY;").close() +- # Recompute remaining timeout after the potentially blocking journal_mode pragma. +- waited = time.perf_counter() - start_time +- if (recomputed := timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)) != timeout_ms: +- self._con.execute(f"PRAGMA busy_timeout={recomputed};").close() +- stmt = "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;" +- self._con.execute(stmt).close() +- if mode == "read": +- # A SELECT is needed to force SQLite to actually acquire the SHARED lock on the database. +- # https://www.sqlite.org/lockingv3.html#transaction_control +- self._con.execute("SELECT name FROM sqlite_schema LIMIT 1;").close() ++ # Refuse a swapped path before any statement can replay an adjacent journal, and after the BEGIN. ++ try: ++ raise_if_database_replaced(self._con, self._canonical_path) ++ with bounded_schema(self._con, self.lock_file): ++ waited = time.perf_counter() - start_time ++ timeout_ms = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited) ++ self._con.execute(f"PRAGMA busy_timeout={timeout_ms};").close() ++ # Use legacy journal mode (not WAL) because WAL does not block readers when a concurrent EXCLUSIVE ++ # write transaction is active, making read-write locking impossible without modifying table data. ++ # MEMORY is safe here since no actual writes happen — crashes cannot corrupt the DB. ++ # See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions ++ # ++ # Set here (not in __init__) because this pragma itself may block on a locked database, ++ # so it must run after busy_timeout is configured above. ++ self._con.execute("PRAGMA journal_mode=MEMORY;").close() ++ # Recompute remaining timeout after the potentially blocking journal_mode pragma. ++ waited = time.perf_counter() - start_time ++ if (recomputed := timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)) != timeout_ms: ++ self._con.execute(f"PRAGMA busy_timeout={recomputed};").close() ++ stmt = "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;" ++ self._con.execute(stmt).close() ++ if mode == "read": ++ # A SELECT is needed to force SQLite to actually acquire the SHARED lock on the database. ++ # https://www.sqlite.org/lockingv3.html#transaction_control ++ self._con.execute("SELECT name FROM sqlite_schema LIMIT 1;").close() ++ raise_if_database_replaced(self._con, self._canonical_path) ++ except BaseException: ++ with suppress(sqlite3.Error): ++ self._con.rollback() ++ raise + + def _acquire(self, mode: Literal["read", "write"], timeout: float, *, blocking: bool) -> AcquireReturnProxy: + opposite = "write" if mode == "read" else "read" +diff --git a/src/filelock/_rw_security.py b/src/filelock/_rw_security.py +index 9e4f92f..82b5803 100644 +--- a/src/filelock/_rw_security.py ++++ b/src/filelock/_rw_security.py +@@ -1,23 +1,36 @@ + from __future__ import annotations + ++import errno ++import hashlib + import os + import pathlib ++import secrets ++import socket + import sqlite3 + import stat + import sys ++import tempfile + import threading + from collections import deque + from contextlib import contextmanager, suppress + from dataclasses import dataclass, field + from itertools import count +-from typing import TYPE_CHECKING, NamedTuple ++from typing import TYPE_CHECKING, Final, NamedTuple + + if TYPE_CHECKING: + from collections.abc import Generator + + _DatabaseIdentity = tuple[int, int] + _DB_OPEN_FLAGS = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) +-_FD_DIR = "/proc/self/fd" if sys.platform == "linux" else "/dev/fd" ++_DIRECTORY_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) ++# Header bytes 18 and 19 hold the file format versions, 2 in WAL mode: https://www.sqlite.org/fileformat.html ++_SQLITE_MAGIC = b"SQLite format 3\x00" ++_FORMAT_VERSIONS = slice(18, 20) ++_HEADER_SIZE = 20 ++_WAL_FORMAT_VERSION = 2 ++_LINK_PREFIX = ".filelock-" ++_LINK_NAME = "lock.db" ++_SWEPT_DATABASES: set[_DatabaseIdentity] = set() + + + def canonical(database: str | os.PathLike[str]) -> pathlib.Path: +@@ -119,21 +132,40 @@ class _ProbeDescriptors: + with suppress(OSError): + os.close(fd) + ++ def _reset_after_fork_in_child(self) -> None: ++ # The child owns no POSIX locks, and must not reuse the parent's descriptor leases. ++ descriptors = self._unclaimed + [fd for probes in self._inodes.values() for fd in probes.descriptors] ++ self._lock = threading.Lock() ++ self._inodes = {} ++ self._unclaimed = [] ++ self._releases = deque() ++ for fd in descriptors: ++ with suppress(OSError): ++ os.close(fd) ++ + + _PROBE_DESCRIPTORS = _ProbeDescriptors() ++if hasattr(os, "register_at_fork"): ++ os.register_at_fork(after_in_child=_PROBE_DESCRIPTORS._reset_after_fork_in_child) + + + class _Connection(sqlite3.Connection): + _probe: _Probe | None = None ++ _database_directory: _LinkDirectory | None = None + database_identity: _DatabaseIdentity | None = None + + def close(self) -> None: + try: + super().close() + finally: +- if (probe := self._probe) is not None: +- self._probe = None +- _PROBE_DESCRIPTORS.release(probe) ++ try: ++ if (directory := self._database_directory) is not None: ++ self._database_directory = None ++ _remove_link_directory(directory) ++ finally: ++ if (probe := self._probe) is not None: ++ self._probe = None ++ _PROBE_DESCRIPTORS.release(probe) + + def __del__(self) -> None: + with suppress(Exception): +@@ -159,19 +191,43 @@ def connect(database: str) -> sqlite3.Connection: + # Windows handle hardening is not part of the POSIX recipe backport. + return sqlite3.connect(database, check_same_thread=False, cached_statements=0) + claimed: list[_Probe] = [] ++ link: _LinkDirectory | None = None + try: + if (probe := _PROBE_DESCRIPTORS.adopt(database, claimed)) is None: + fd, identity = _open_lock_database(database) + probe = _PROBE_DESCRIPTORS.retain(fd, identity, claimed) + _PROBE_DESCRIPTORS.drain() +- connection = sqlite3.connect(f"{_FD_DIR}/{probe.fd}", check_same_thread=False, factory=_Connection, cached_statements=0) ++ _raise_if_wal(probe.fd, database) ++ target = database ++ # SQLite resolves descriptor paths back to names and then opens adjacent journal/WAL files. Only a name in ++ # a directory no other account can write is safe; otherwise use an identity-checked private hard link. ++ if not _names_are_private(pathlib.Path(database).parent, os.geteuid()): ++ link = _link_beside(database, probe.identity, os.geteuid()) ++ target = os.fspath(link.database) ++ connection = sqlite3.connect(target, check_same_thread=False, factory=_Connection, cached_statements=0) + connection._probe = probe + connection.database_identity = probe.identity ++ connection._database_directory = link + claimed.clear() + return connection + finally: +- for probe in claimed: +- _PROBE_DESCRIPTORS.release(probe) ++ for unowned in claimed: ++ _PROBE_DESCRIPTORS.release(unowned) ++ if claimed and link is not None: ++ _remove_link_directory(link) ++ ++ ++def raise_if_database_replaced(connection: sqlite3.Connection, database: pathlib.Path) -> None: ++ if sys.platform == "win32": ++ return ++ try: ++ status = database.stat(follow_symlinks=False) ++ except OSError as exc: ++ msg = f"lock database was replaced while acquiring: {database!s}" ++ raise OSError(msg) from exc ++ if (status.st_dev, status.st_ino) != connection.database_identity: ++ msg = f"lock database was replaced while acquiring: {database!s}" ++ raise OSError(msg) + + + @contextmanager +@@ -195,3 +251,192 @@ def bounded_schema(connection: sqlite3.Connection, database: str) -> Generator[N + raise + finally: + connection.set_progress_handler(None, 0) ++ ++ ++def _names_are_private(directory: pathlib.Path, owner: int) -> bool: # pragma: win32 no cover ++ status: Final = os.lstat(directory) ++ # The sticky bit still lets others create names, so a directory anyone else may write to never qualifies. ++ return status.st_uid in {owner, 0} and not status.st_mode & (stat.S_IWGRP | stat.S_IWOTH) ++ ++ ++class _LinkDirectory(NamedTuple): # pragma: needs posix-hard-link ++ path: pathlib.Path ++ identity: _DatabaseIdentity ++ # Made through a descriptor of path, which nobody else can list, so a directory another process swaps in under ++ # path cannot hold this name and SQLite fails to open instead of following the swap. ++ name: str ++ ++ @property ++ def database(self) -> pathlib.Path: ++ return self.path / self.name / _LINK_NAME ++ ++ ++def _link_beside( # pragma: needs posix-hard-link ++ database: str, identity: _DatabaseIdentity, owner: int ++) -> _LinkDirectory: ++ # Beside the database because a link cannot cross filesystems, and TMPDIR is often on another one. ++ parent: Final = pathlib.Path(database).parent ++ _sweep_links_of_the_dead(parent, identity, owner) ++ path: Final = pathlib.Path(tempfile.mkdtemp(prefix=_link_prefix(), dir=parent)) ++ fd: Final = os.open(path, _DIRECTORY_FLAGS) ++ try: ++ made: Final = os.fstat(fd) ++ # Whoever can rename in the parent can put another directory under the new name before it opens. ++ if made.st_uid != owner or made.st_mode & 0o077: ++ msg = f"link directory {os.fspath(path)!r} was replaced while opening" ++ raise OSError(msg) ++ link: Final = _LinkDirectory(path, (made.st_dev, made.st_ino), secrets.token_hex(8)) ++ try: ++ os.mkdir(link.name, 0o700, dir_fd=fd) ++ os.link(database, link.database, follow_symlinks=False) ++ linked: Final = link.database.stat(follow_symlinks=False) ++ except BaseException: ++ _remove_link_directory(link) ++ raise ++ finally: ++ os.close(fd) ++ if (linked.st_dev, linked.st_ino) != identity: ++ _remove_link_directory(link) ++ msg = f"lock database changed while opening: {database!r}" ++ raise OSError(msg) ++ return link ++ ++ ++def _sweep_links_of_the_dead( ++ parent: pathlib.Path, identity: _DatabaseIdentity, owner: int ++) -> None: # pragma: needs posix-hard-link ++ # A process killed while it held a link leaves the directory, and an extra link to the database, behind for good. ++ # Two threads sweeping at once only both try the same removals, which a lock would cost a fork-safety reset for. ++ if identity in _SWEPT_DATABASES: ++ return ++ _SWEPT_DATABASES.add(identity) ++ try: ++ with os.scandir(parent) as entries: ++ names: Final = [ ++ entry.name ++ for entry in entries ++ if entry.name.startswith(_LINK_PREFIX) and entry.is_dir(follow_symlinks=False) ++ ] ++ except OSError: ++ return ++ for name in names: ++ # A peer sweeping alongside, or a directory this user cannot remove, must not fail the connect. ++ with suppress(OSError): ++ _remove_dead_links(parent / name, identity, owner) ++ ++ ++def _remove_dead_links( ++ path: pathlib.Path, identity: _DatabaseIdentity, owner: int ++) -> None: # pragma: needs posix-hard-link ++ # Any directory can carry a dead holder's name after a rename in the parent, so only hard links to this database ++ # go, and the directory only once that leaves it empty. ++ fd: Final = os.open(path, _DIRECTORY_FLAGS) ++ try: ++ # Only a directory this account made carries a holder of its own; a name another account chose is never ++ # parsed, so no PID is probed on its behalf. ++ if os.fstat(fd).st_uid != owner or not _owner_is_gone(path.name): ++ return ++ with os.scandir(fd) as entries: ++ names: Final = [entry.name for entry in entries] ++ for entry in names: ++ # Releases up to 4.2.0 linked the database right inside the directory. ++ if entry == _LINK_NAME: ++ _unlink_link_to(identity, fd) ++ continue ++ try: ++ nested = os.open(entry, _DIRECTORY_FLAGS, dir_fd=fd) ++ except OSError: ++ continue ++ try: ++ _unlink_link_to(identity, nested) ++ finally: ++ os.close(nested) ++ with suppress(OSError): ++ os.rmdir(entry, dir_fd=fd) ++ finally: ++ os.close(fd) ++ with suppress(OSError): ++ path.rmdir() ++ ++ ++def _unlink_link_to(identity: _DatabaseIdentity, dir_fd: int) -> None: # pragma: needs posix-hard-link ++ try: ++ linked: Final = os.stat(_LINK_NAME, dir_fd=dir_fd, follow_symlinks=False) ++ except OSError: ++ return ++ if (linked.st_dev, linked.st_ino) == identity: ++ os.unlink(_LINK_NAME, dir_fd=dir_fd) ++ ++ ++def _remove_link_directory(link: _LinkDirectory) -> None: # pragma: needs posix-hard-link ++ # A rename in the parent may have put any directory under this name, so entries go through a descriptor checked ++ # to be the directory made, and the name goes only through rmdir, which refuses a non-empty directory. ++ try: ++ fd: Final = os.open(link.path, _DIRECTORY_FLAGS) ++ except OSError: ++ return ++ try: ++ opened: Final = os.fstat(fd) ++ if (opened.st_dev, opened.st_ino) != link.identity: ++ return ++ for suffix in ("", "-journal", "-wal", "-shm"): ++ with suppress(FileNotFoundError): ++ os.unlink(f"{link.name}/{_LINK_NAME}{suffix}", dir_fd=fd) ++ with suppress(FileNotFoundError): ++ os.rmdir(link.name, dir_fd=fd) ++ finally: ++ os.close(fd) ++ # The link is gone, so a failure here leaves one stray empty directory and is not worth failing a release for. ++ with suppress(OSError): ++ link.path.rmdir() ++ ++ ++def _raise_if_wal(fd: int, database: str) -> None: ++ # SQLite derives the -wal name from the path it connects through, a descriptor name on POSIX, and WAL never blocks ++ # readers behind a writer, so the lock cannot work on such a database. ++ # pread: an adopted probe is shared, so its file offset is not this connect's to move. ++ header: Final = os.pread(fd, _HEADER_SIZE, 0) if hasattr(os, "pread") else os.read(fd, _HEADER_SIZE) ++ if header.startswith(_SQLITE_MAGIC) and _WAL_FORMAT_VERSION in header[_FORMAT_VERSIONS]: ++ msg = f"refusing a WAL-mode lock database: {database!r}; give ReadWriteLock a file of its own" ++ raise ValueError(msg) ++ ++ ++def _process_start_token(pid: int) -> str | None: ++ # Linux proc field 22 identifies a process despite PID reuse. Other POSIX systems can still safely test liveness. ++ if sys.platform != "linux": ++ return None ++ try: ++ return pathlib.Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").rpartition(")")[2].split()[19] ++ except (OSError, IndexError): ++ return None ++ ++ ++def _host_digest(host: str) -> str: ++ return hashlib.sha256(host.encode()).hexdigest()[:16] ++ ++ ++def _link_prefix() -> str: ++ pid = os.getpid() ++ return f"{_LINK_PREFIX}{pid}-{_process_start_token(pid) or ''}-{_host_digest(socket.gethostname())}-" ++ ++ ++def _owner_is_gone(name: str) -> bool: ++ parts = name.removeprefix(_LINK_PREFIX).split("-") ++ if len(parts) != 4: ++ return False ++ pid, start, host, _ = parts ++ if not pid.isascii() or not pid.isdigit() or len(pid) > 10: ++ return False ++ owner_pid = int(pid) ++ if not 1 <= owner_pid <= 2**31 - 1: ++ return False ++ if start and (not start.isascii() or not start.isdigit()): ++ return False ++ if host != _host_digest(socket.gethostname()): ++ return False ++ try: ++ os.kill(owner_pid, 0) ++ except OSError as exc: ++ return exc.errno == errno.ESRCH ++ current = _process_start_token(owner_pid) ++ return bool(start and current is not None and current != start) +diff --git a/tests/test_read_write_security.py b/tests/test_read_write_security.py +index a995c80..992cd75 100644 +--- a/tests/test_read_write_security.py ++++ b/tests/test_read_write_security.py +@@ -3,11 +3,12 @@ from __future__ import annotations + import errno + import os + import struct +-from typing import Literal + import subprocess + import sys ++from contextlib import closing + from pathlib import Path + from collections.abc import Generator ++from typing import Literal + + import pytest + +@@ -166,6 +167,174 @@ def _varint(value: int) -> bytes: + return bytes(groups) + + ++@pytest.fixture ++def shared_database(tmp_path: Path) -> Path: ++ shared = tmp_path / "shared" ++ shared.mkdir() ++ shared.chmod(0o777) ++ return shared / "lock.db" ++ ++ ++def _hot_journal(super_journal: Path) -> bytes: ++ # A journal whose tail names a super-journal makes SQLite unlink that file once it finishes the journal: ++ # https://www.sqlite.org/fileformat.html#the_rollback_journal ++ name = os.fsencode(super_journal) ++ return name + struct.pack(">II", len(name), sum(name)) + bytes.fromhex("d9d505f920a163d7") ++ ++ ++def _entries(directory: Path) -> list[tuple[str, bytes | None]]: ++ return sorted((path.name, path.read_bytes() if path.is_file() else None) for path in directory.iterdir()) ++ ++ ++def test_shared_directory_ignores_a_journal_beside_the_database(shared_database: Path, tmp_path: Path) -> None: ++ # SQLite treats a journal beside a non-empty database as an interrupted transaction, and finishing it deletes the ++ # super-journal file the journal names. ++ named = tmp_path / "notes.txt" ++ named.write_text("keep") ++ with closing(sqlite3.connect(shared_database)) as connection: ++ connection.execute("CREATE TABLE application (value)") ++ Path(f"{shared_database}-journal").write_bytes(_hot_journal(named)) ++ lock = ReadWriteLock(shared_database, is_singleton=False) ++ try: ++ with lock.read_lock(): ++ pass ++ finally: ++ lock.close() ++ assert named.read_text() == "keep" ++ ++ ++def test_shared_directory_ignores_a_directory_at_the_wal_name(shared_database: Path) -> None: ++ Path(f"{shared_database}-wal").mkdir() ++ lock = ReadWriteLock(shared_database, is_singleton=False) ++ try: ++ with lock.write_lock(): ++ pass ++ finally: ++ lock.close() ++ ++ ++def test_private_alias_cleanup_waits_for_connection_close(shared_database: Path) -> None: ++ lock = ReadWriteLock(shared_database, is_singleton=False) ++ link = lock._con._database_directory ++ assert link is not None ++ assert link.path.parent == shared_database.parent ++ try: ++ assert link.database.stat().st_ino == shared_database.stat().st_ino ++ assert link.path.stat().st_mode & 0o777 == 0o700 ++ with lock.read_lock(): ++ pass ++ # filelock 3.25.2 keeps its connection across releases, so its safe name must live just as long. ++ assert link.database.exists() ++ with lock.write_lock(): ++ pass ++ finally: ++ lock.close() ++ assert not link.path.exists() ++ ++ ++def test_private_alias_failure_preserves_a_peer_lock(shared_database: Path, monkeypatch: pytest.MonkeyPatch) -> None: ++ holder = ReadWriteLock(shared_database, is_singleton=False) ++ try: ++ holder.acquire_write() ++ def deny_link(*args: object, **kwargs: object) -> None: ++ raise PermissionError("no hard links") ++ monkeypatch.setattr(os, "link", deny_link) ++ with pytest.raises(PermissionError, match="no hard links"): ++ ReadWriteLock(shared_database, is_singleton=False) ++ result = subprocess.run( ++ [sys.executable, "-c", "import sqlite3,sys; c=sqlite3.connect(sys.argv[1], timeout=0); c.execute('BEGIN EXCLUSIVE')", str(shared_database)], ++ capture_output=True, text=True, check=False, ++ ) ++ assert result.returncode != 0 ++ assert "database is locked" in result.stderr ++ finally: ++ holder.close() ++ ++ ++def test_private_alias_cleanup_keeps_a_directory_renamed_onto_its_name(shared_database: Path, tmp_path: Path) -> None: ++ lock = ReadWriteLock(shared_database, is_singleton=False) ++ link = lock._con._database_directory ++ assert link is not None ++ link.path.rename(tmp_path / "moved") ++ link.path.mkdir(mode=0o700) ++ note = link.path / "notes.txt" ++ note.write_text("keep") ++ lock.close() ++ assert note.read_text() == "keep" ++ ++ ++def test_acquire_refuses_a_replaced_database_before_statements(shared_database: Path, tmp_path: Path) -> None: ++ lock = ReadWriteLock(shared_database, is_singleton=False) ++ replacement = tmp_path / "replacement.db" ++ replacement.write_bytes(b"keep") ++ replacement.replace(shared_database) ++ try: ++ with pytest.raises(OSError, match="replaced while acquiring"): ++ lock.acquire_write() ++ assert shared_database.read_bytes() == b"keep" ++ assert not lock._con.in_transaction ++ finally: ++ lock.close() ++ ++ ++def test_shared_directory_keeps_sqlite_off_an_unlinked_name( ++ shared_database: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ++) -> None: ++ target = tmp_path / "target.txt" ++ Path(f"{shared_database} (deleted)").symlink_to(target) ++ shared_database.touch() ++ kept = shared_database.with_name("kept.db") ++ kept.hardlink_to(shared_database) ++ connect = sqlite3.connect ++ def relink_then_connect(path: str, **kwargs: object) -> sqlite3.Connection: ++ shared_database.unlink() ++ shared_database.hardlink_to(kept) ++ return connect(path, **kwargs) ++ monkeypatch.setattr(sqlite3, "connect", relink_then_connect) ++ ReadWriteLock(shared_database, is_singleton=False).close() ++ assert not target.exists() ++ ++ ++def test_private_directory_under_shared_parent_refuses_replacement( ++ shared_database: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ++) -> None: ++ private = shared_database.parent / "private" ++ private.mkdir(mode=0o700) ++ database = private / "lock.db" ++ moved = shared_database.parent / "moved" ++ note = tmp_path / "notes.txt" ++ note.write_text("keep") ++ connect = sqlite3.connect ++ replaced: list[list[tuple[str, bytes | None]]] = [] ++ def replace_then_connect(path: str, **kwargs: object) -> sqlite3.Connection: ++ private.rename(moved) ++ private.mkdir(mode=0o700) ++ with closing(connect(database)) as created: ++ created.execute("CREATE TABLE application (value)") ++ Path(f"{database}-journal").write_bytes(_hot_journal(note)) ++ replaced.append(_entries(private)) ++ return connect(path, **kwargs) ++ monkeypatch.setattr(sqlite3, "connect", replace_then_connect) ++ lock = ReadWriteLock(database, is_singleton=False) ++ try: ++ with pytest.raises(OSError, match="replaced while acquiring"): ++ lock.acquire_write() ++ assert note.read_text() == "keep" ++ assert _entries(private) == replaced[0] ++ finally: ++ lock.close() ++ ++ ++def test_wal_mode_database_is_refused(tmp_path: Path) -> None: ++ database = tmp_path / "lock.db" ++ with closing(sqlite3.connect(database)) as created: ++ created.execute("PRAGMA journal_mode=WAL") ++ created.execute("CREATE TABLE application(value)") ++ created.commit() ++ with pytest.raises(ValueError, match="WAL-mode"): ++ ReadWriteLock(database, is_singleton=False) ++ ++ + def _fail_opened_database_fstat(database: Path, monkeypatch: pytest.MonkeyPatch) -> list[int]: + opened: list[int] = [] + real_open = os.open +@@ -219,3 +388,22 @@ def test_failed_database_fstat_preserves_a_peer_lock(tmp_path: Path, monkeypatch + with pytest.raises(OSError) as caught: + os.fstat(opened[0]) + assert caught.value.errno == errno.EBADF ++ ++ ++@pytest.mark.parametrize("pid", ["2147483648", "9999999999", "999999999999999999999999999999"]) ++def test_private_link_sweep_ignores_out_of_range_owner_pids( ++ shared_database: Path, monkeypatch: pytest.MonkeyPatch, pid: str ++) -> None: ++ host = _rw_security._host_digest(_rw_security.socket.gethostname()) ++ planted = shared_database.parent / f".filelock-{pid}--{host}-planted" ++ planted.mkdir(mode=0o700) ++ def unexpected_probe(owner_pid: int, signal: int) -> None: ++ raise AssertionError("must not probe an out-of-range owner PID") ++ monkeypatch.setattr(os, "kill", unexpected_probe) ++ lock = ReadWriteLock(shared_database, is_singleton=False) ++ try: ++ with lock.read_lock(): ++ pass ++ finally: ++ lock.close() ++ assert planted.is_dir() +diff --git a/tests/test_read_write_unit.py b/tests/test_read_write_unit.py +index 3a2d272..f657f9a 100644 +--- a/tests/test_read_write_unit.py ++++ b/tests/test_read_write_unit.py +@@ -525,12 +525,18 @@ def test_operational_error_handling( + mode: Literal["read", "write"], + ) -> None: + lock = ReadWriteLock(lock_file, is_singleton=False) ++ original = lock._con + con_mock = mocker.MagicMock() ++ con_mock.database_identity = original.database_identity + con_mock.execute.side_effect = sqlite3.OperationalError(error_msg) + lock._con = con_mock +- acquire = lock.acquire_read if mode == "read" else lock.acquire_write +- with pytest.raises(expected_exception): +- acquire() ++ try: ++ acquire = lock.acquire_read if mode == "read" else lock.acquire_write ++ with pytest.raises(expected_exception): ++ acquire() ++ finally: ++ lock._con = original ++ lock.close() + + + def test_write_lock_context_manager_overrides_defaults(lock_file: str) -> None: diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112940_p2.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112940_p2.patch new file mode 100644 index 0000000000..5fc7faf5d8 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112940_p2.patch @@ -0,0 +1,104 @@ +From 21bc250542497ff2d74151cdf7f98069d929465a Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Fri, 30 Oct 2026 12:20:29 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(rw):=20close=20a=20link=20di?= + =?UTF-8?q?rectory=20an=20ACL=20opened=20(#898)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-112940 +Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/21bc250542497ff2d74151cdf7f98069d929465a] + +Backport Changes: +- Apply POSIX inherited-ACL tightening to _rw_security.py, where + the preceding backport keeps its private-directory helpers. +- Adapt the ACL regressions to the dedicated security test file, + persistent connection lifetime and pytest monkeypatch fixture. +- Omit the standalone changelog fragment, which is absent from the + 3.25.2 source archive. + +Signed-off-by: Darsh Kelaiya +--- + src/filelock/_rw_security.py | 16 +++++++++++++++- + tests/test_read_write_security.py | 38 ++++++++++++++++++++++++++++++++++++++ + 2 files changed, 53 insertions(+), 1 deletion(-) + +diff --git a/src/filelock/_rw_security.py b/src/filelock/_rw_security.py +index 82b5803..f02c4bf 100644 +--- a/src/filelock/_rw_security.py ++++ b/src/filelock/_rw_security.py +@@ -282,9 +282,23 @@ def _link_beside( # pragma: needs posix-hard-link + try: + made: Final = os.fstat(fd) + # Whoever can rename in the parent can put another directory under the new name before it opens. +- if made.st_uid != owner or made.st_mode & 0o077: ++ loose: Final = made.st_mode & 0o077 ++ if made.st_uid != owner or (loose and os.listdir(fd)): + msg = f"link directory {os.fspath(path)!r} was replaced while opening" + raise OSError(msg) ++ # An inherited ACL, such as an NFSv4 one on ZFS, overrides the mode mkdtemp asks for. Only an empty directory ++ # is tightened, so one swapped in with contents is still refused above. ++ if loose: ++ msg = ( ++ f"link directory {os.fspath(path)!r} stays open to other accounts through an inherited ACL" ++ f" that chmod 0700 cannot close; make {os.fspath(parent)!r} writable by its owner only" ++ ) ++ try: ++ os.fchmod(fd, 0o700) ++ except PermissionError as exc: # ZFS with aclmode=restricted refuses a chmod on a non-trivial ACL ++ raise OSError(msg) from exc ++ if os.fstat(fd).st_mode & 0o077: ++ raise OSError(msg) + link: Final = _LinkDirectory(path, (made.st_dev, made.st_ino), secrets.token_hex(8)) + try: + os.mkdir(link.name, 0o700, dir_fd=fd) +diff --git a/tests/test_read_write_security.py b/tests/test_read_write_security.py +index 992cd75..4d0a6ae 100644 +--- a/tests/test_read_write_security.py ++++ b/tests/test_read_write_security.py +@@ -335,6 +335,44 @@ def test_wal_mode_database_is_refused(tmp_path: Path) -> None: + ReadWriteLock(database, is_singleton=False) + + ++@pytest.fixture ++def loosened_directories(monkeypatch: pytest.MonkeyPatch) -> list[Path]: ++ directories: list[Path] = [] ++ mkdtemp = _rw_security.tempfile.mkdtemp ++ def create_with_group_access(*, prefix: str, dir: Path) -> str: ++ # Stands in for an inheritable ACL entry that grants the group access to every new directory. ++ directory = Path(mkdtemp(prefix=prefix, dir=dir)) ++ directory.chmod(0o770) ++ directories.append(directory) ++ return str(directory) ++ monkeypatch.setattr(_rw_security.tempfile, "mkdtemp", create_with_group_access) ++ return directories ++ ++ ++def test_private_link_closes_a_mode_an_inherited_acl_opened( ++ shared_database: Path, loosened_directories: list[Path] ++) -> None: ++ lock = ReadWriteLock(shared_database, is_singleton=False) ++ try: ++ with lock.read_lock(): ++ assert [directory.stat().st_mode & 0o777 for directory in loosened_directories] == [0o700] ++ finally: ++ lock.close() ++ ++ ++@pytest.mark.parametrize("refused", [False, True]) ++def test_private_link_refuses_a_directory_chmod_cannot_close( ++ shared_database: Path, loosened_directories: list[Path], monkeypatch: pytest.MonkeyPatch, refused: bool ++) -> None: ++ def fchmod(fd: int, mode: int) -> None: ++ if refused: ++ raise PermissionError("Operation not permitted") ++ monkeypatch.setattr(os, "fchmod", fchmod) ++ with pytest.raises(OSError, match="writable by its owner only"): ++ ReadWriteLock(shared_database, is_singleton=False) ++ assert len(loosened_directories) == 1 ++ ++ + def _fail_opened_database_fstat(database: Path, monkeypatch: pytest.MonkeyPatch) -> list[int]: + opened: list[int] = [] + real_open = os.open diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb index 4f059e438e..6f52b19272 100644 --- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb +++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb @@ -16,6 +16,8 @@ SRC_URI += " file://run-ptest \ file://CVE-2026-112909_p2.patch \ file://CVE-2026-112936.patch \ file://CVE-2026-112915.patch \ + file://CVE-2026-112940_p1.patch \ + file://CVE-2026-112940_p2.patch \ " SRC_URI[sha256sum] = "b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694" @@ -28,6 +30,7 @@ DEPENDS += "\ " RDEPENDS:${PN} += " \ + python3-crypt \ python3-ctypes \ python3-core \ python3-logging \ From patchwork Wed Nov 11 05:29:36 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" X-Patchwork-Id: 103665 Received: from rcdn-iport-3.cisco.com (rcdn-iport-3.cisco.com [173.37.86.74]) by mx.groups.io with SMTP id smtpd.msgproc02-g2.3285.1794374984723547537 for ; Tue, 10 Nov 2026 21:29:44 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=QVBN91Ht; spf=pass (domain: cisco.com, ip: 173.37.86.74, mailfrom: dkelaiya@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=1465; q=dns/txt; s=iport01; t=1794374984; x=1795584584; h=from:to:cc:subject:date:message-id:in-reply-to: references:mime-version:content-transfer-encoding; bh=jYZ0UsHq9RpWo8M2PZfs2Hdc+FTUR3kxD7Ympo6//SQ=; b=QVBN91HtE9XzZ3rA8hMHQ+9aO5fE6I/ub4lak+p+BaflAiezU0/64FQD /I5FTGhILNw223q+e0yhqjUyvT4MWNMVOjE+yAc6kYN5bZy0TJ+ruyKCP htBQue+K0KO/kLomhdMbg3PYn47ct9BnFRW015xO9zvZjZbOO/4rJ7el7 N4OV17p4JS4igw9mm6MzyARJS56RzCwWh97r09SKWh99Cjfqo/iL8DTfu 2zyquLe3UxyqW5hD1kf0NA7rC+CWX3N3ssFLyjzL03BAb0KeTK+vLXa/n Lbhw4J5Cmnb0rpkP0Tnuf6GjKn0HEy977iwt/4hFGmmuAkN5nnswuWvGw A==; X-CSE-ConnectionGUID: LtnjLtckQRyHN7+RzjkikA== X-CSE-MsgGUID: qULbkYrwSJiqweq7pNuirA== X-IPAS-Result: A0BHAgDP+/Nq/4r/Ja1aglmCV3VgQ0kDlVxvgROdD4F+DwEBAQ9EDQQBAYQ/RgKOHgImNAkOAQIEAwIDAQEBAQEBAQEBAQEBCgEBBQEBAQIBBwWBDhOGTw2QEwIBAzIBGAEtECAxKx0BDRmDAgGCdAMRuVaCLIEBgykBPwJDUNszAQsUAYE4hUKIJHUBhH0nGxuBcoEVgnN2gQWBXAEBgi2FVyIEgiKBDIJjjBGIdkiBHgNZLAFVEw0KCwcFgWgDNRIqFW4yHYEjPheBBxsGBYEcgR6BEoEHXYErKF0BIYEIgXYCglGCAAIBSUEOB0VTCSVDEkckIggSCQETGiwKgRo1PAkmPxUMZwsaBWEIAg0MdDkQMB4kLwUaJj4bCn4YDUgRLDcVGQQ+bgeSboMVgQ4sBFCBWJNJki2hEQoog3iMJJU6GjOqcguYfY4KllGEaoFoPIFHCwdwFYMiCUoZD44vCguDYIF/zFwmMgIMLwEBBwIHDgMLgWiRfgEB IronPort-Data: A9a23:DJaMaauvbqTiWJByXqEQB7JDMefnVAZfMUV32f8akzHdYApBsoF/q tZmKT/UMqyDYGKkLttybY+y/B5UuMLQxoc2QQA6/CE3Rn8VgMeUXt7xwmUckM+xwmwvaGo9s q3yv/GZdJhcokf0/0nrb/656yYsjclkf5KkYMbcICd9WAR4fykojBNnioYRj5Vh6TSDK1vlV eja/YuFZzdJ5xYuajhKsvva8Us21BjPkGpwUmIWNKgjUGD2zxH5PLpHTYmtIn3xRJVjH+LSb 47r0LGj82rFyAwmA9Wjn6yTWhVirmn6ZFXmZtJ+AsBOszAazsAA+v9T2Mk0NS+7vw60c+VZk 72hg3AfpTABZcUgkMxFO/VR/roX0aduoNcrKlDn2SCfItGvn3bEm51T4E8K0YIw3vpxA0VS/ v8hEiEtSE/AvfC0w7O4Y7w57igjBJGD0II3oHpsy3TdSP0hW52GG/yM7t5D1zB2jcdLdRrcT 5NGMnw0M1KaPkAJYwxHYH49tL/Aan3Xfz5VrFuUtKMf6GnIxws327/oWDbQUoHWGp0MwRzH+ Aoq+UzgPzUQN+2F1gGn82uzi/aQhQXmWZg7QejQGvlCxQf7KnYoIBoOWF22pPO0hkKzV5dTJ lIZ/gIqrLMu7wqsVtT7UhiyrXKIsxJaXMBfe9DW8ymXwabSpgLcDW8eQ3sZMZottdQ9Qnoh0 Vrhc87VOAGDeYa9ERq1nop4ZxvoUcTJBQfuvRM5cDY= IronPort-HdrOrdr: A9a23:FHUnHqNjwCUU3sBcTuOjsMiBIKoaSvp037Dk7S9MoHtuA6ulfq +V/cjzuSWYtN9VYgBDpTniAtjlfZqjz/5ICOAqVN/INjUO+lHYSb2KhrGN/9SPIUHDH5ZmpM RdWpk7LsHsBl5nisu/ygy5H9E8hOSjysmT9IHjJ7MHd3ATV0mmhD0JczqmLg== X-Talos-CUID: 9a23:HmMYdGFv+nZgBMZDqmJD6EgWPtwucUTn3Vf2fAyCG2RqSb68HAo= X-Talos-MUID: 9a23:ccxqCQ4EMzhrWsSSOFHfmYjnxoxx3oCkC3EXjq4kutCbODUvIxGAjS6eF9o= X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,207,1787011200"; d="scan'208";a="547017276" Received: from rcdn-l-core-01.cisco.com ([173.37.255.138]) by rcdn-iport-3.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 11 Nov 2026 05:29:43 +0000 Received: from sjc-ads-5675.cisco.com (sjc-ads-5675.cisco.com [10.28.88.189]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by rcdn-l-core-01.cisco.com (Postfix) with ESMTPS id 632FD180001DC; Wed, 11 Nov 2026 05:29:43 +0000 (GMT) Received: by sjc-ads-5675.cisco.com (Postfix, from userid 1887444) id C26F1CBEF8A; Tue, 10 Nov 2026 21:29:42 -0800 (PST) From: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" To: openembedded-devel@lists.openembedded.org Cc: Darsh Kelaiya Subject: [oe][meta-python][wrynose][PATCH 7/9] python3-filelock: ignore CVE-2026-112907 Date: Tue, 10 Nov 2026 21:29:36 -0800 Message-Id: <20261111052938.3691463-8-dkelaiya@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261111052938.3691463-1-dkelaiya@cisco.com> References: <20261111052938.3691463-1-dkelaiya@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: ANONYMOUS;sjc-ads-5675.cisco.com [10.28.88.189];TLSv1.3;TLS_AES_256_GCM_SHA384;256 X-Outbound-SMTP-Client: 10.28.88.189, sjc-ads-5675.cisco.com X-Outbound-Node: rcdn-l-core-01.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Wed, 11 Nov 2026 05:29:50 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/132119 From: Darsh Kelaiya Analysis: - The upstream advisory describes a Windows-only denial of service caused by an unanswered oplock on a soft-lock marker. [1][2] - Marker reads and stale-marker removal can exceed the acquisition timeout, including in asynchronous and fallback locks on Windows. [1] - The Wrynose Linux target does not use the affected Windows oplock operations. [1][2] - Mark the CVE as not applicable to the Linux target platform. Reference: [1] https://github.com/tox-dev/filelock/security/advisories/GHSA-567q-5qvw-pf92 [2] https://nvd.nist.gov/vuln/detail/CVE-2026-112907 Signed-off-by: Darsh Kelaiya --- meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb index 6f52b19272..814fd59eb3 100644 --- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb +++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb @@ -7,6 +7,8 @@ LIC_FILES_CHKSUM = "file://LICENSE;md5=2c6acbdf7bb74caa37512c3a5ca6857b" CVE_PRODUCT = "tox-dev:filelock" +CVE_STATUS[CVE-2026-112907] = "not-applicable-platform: Windows oplock handling is not used on Linux" + SRC_URI += " file://run-ptest \ file://CVE-2026-112939.patch \ file://CVE-2026-112935_p1.patch \ From patchwork Wed Nov 11 05:29:37 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" X-Patchwork-Id: 103664 Received: from rcdn-iport-2.cisco.com (rcdn-iport-2.cisco.com [173.37.86.73]) by mx.groups.io with SMTP id smtpd.msgproc02-g2.3284.1794374984292199101 for ; Tue, 10 Nov 2026 21:29:44 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=jKWWbeS/; spf=pass (domain: cisco.com, ip: 173.37.86.73, mailfrom: dkelaiya@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=1588; q=dns/txt; s=iport01; t=1794374984; x=1795584584; h=from:to:cc:subject:date:message-id:in-reply-to: references:mime-version:content-transfer-encoding; bh=zjArlukhizokAvRTPKfULiM9ObmMO3k2vSreoLmorI4=; b=jKWWbeS/bjS68Lf0UZsulYWw56MEBQ05nC+HaCTcWTbTYQBQY7qTgvPB TL0Ttaa17I20t+Lrnm6IgceTb7qHTX+Qf1sHywO/WCDdwZynQ9XEwurre Kutq6S4nJ9GfMkOR7z65q8E7LwP4XW38Qg3JPzoOS06uxvo2Ios6fR9ZQ bw7sR4pD/Qe1BeFJKbNuSSeDPrprwg/8OesCG7d4j8liy8DJmmN4XfVFz 8CquDkraVly0x2TtVAzBYNzhBeUpmDSPBJ1uAEj3h8LLVetmQ9YpS+c8a twflR+iAULqgfSByJwdU46e6pyDz2runXdjc+IJwR76/6pLGdZXztKKzS A==; X-CSE-ConnectionGUID: UspM4Gt3TtWGtDWIhrRywA== X-CSE-MsgGUID: JsRV9DeaSYqMGE1mv1QaOg== X-IPAS-Result: A0BHAgDP+/Nq/4v/Ja1aHgEBCxIMggULgld1YENJA5VcbAOBE50PgX4PAQEBD0QNBAEBhD9GAo4eAiY0CQ4BAgQDAgMBAQEBAQEBAQEBAQEKAQEFAQEBAgEHBYEOE4ZPDZATAgEDMgEYAS0QIDErHQENGYMCAYJ0AxG5VoIsgQGDKQE/AkNQ2zMBCxQBgTiFQogkdQGEfScbG4FygRWCc3aBBYFcAQGCLYVXIgSCIoEMjnSIdkiBHgNZLAFVEw0KCwcFgWgDNRIqFW4yHYEjPheBBxsGBYEcgR6BEoEHXYErKF0BIYEIgXYCglGCAAIBSUEOB0VTCSVDEkckIggSCQETGiwKgRo1PAkmPxUMZwsaBWEIAg0MdDkQMB4kLwUaJj4bCn4YDUgRLDcVGQQ+bgeSboMVgQ4sBBx5Fn2ldqERCiiDeIwkg12RXRozqnILmH2OCpZRhGqBaDxGgQELB3AVgyIJShkPji8KC4NggX/MXCYyAgwvAQEHAgcOAwuBaJF+AQE IronPort-Data: A9a23:ofrLm61YuqLTgLZDvvbD5YNwkn2cJEfYwER7XKvMYLTBsI5bp2MCn TQcW2iAPKuLYmDzKdAnO4+xoxkPsMDUzIRnGlNk3Hw8FHgiRegpqji6wuYcGwvIc6UvmWo+t 512huHodZ5yEzmE4Ej9atANlFEkvYmQXL3wFeXYDS54QA5gWU8JhAlq8wIDqtYAbeORXUXX5 rsen+WFYAX7gmctazpNg06+gEoHUMra6WtwUmMWPZinjHeG/1EJAZQWI72GLneQauF8Au6gS u/f+6qy92Xf8g1FIovNfmHTKxBirhb6ZGBiu1IOM0SQqkEqSh8ajs7XAMEhhXJ/0F1lqTzeJ OJl7vRcQS9xVkHFdX90vxNwS0mSNoUekFPLzOTWXcG7lyX7n3XQL/pGMVouGNE35r1LHV5h6 dAnCTAXVAGcrrfjqF67YrEEasULNsLnOsYb/3pn1zycVaxgSpHYSKKM7thdtNsyrpkRRrCFO IxDNGcpNU+RC/FMEg9/5JYWkOSlgnD+YjRwo1OOrq1x6G/WpOB0+OW2aoCLIIHUFK25mG66v Vj4rkj0CyorLfmw5GOA7CuVj8jAyHaTtIU6UefQGuRRqFqLy2oeDRcbWVe2rby1h1CzX/pbK lcI4WwptaU0+UmhQ9XxUhH+p2SL1iPwQPJKGOE8rQXIwa3O7kPBXC4PTyVKb5ots8peqSEW6 2JlVujBXVRH2IB5g1rEnltIhVte4RQoEFI= IronPort-HdrOrdr: A9a23:4bMO2KuuPb1/R3pgGdjM1J9d7skDVNV00zEX/kB9WHVpmwKj+P xG+85rsiMc5wxxZJhNo7290ey7MBHhHP1OkO0s1MmZPDUO0VHAROoJ0WKh+UyEJ8SUzIBgPM lbH5SWcOeAbmRSvILd/BSyFcomzZ2s9aClgvqb8lJWJDsaDZ2JK2xCe36m+oocfng+OaYE X-Talos-CUID: 9a23:IhX4d25Lurspuk6uQdssyVUMP5wKbibh0Ej/P2OHKGZzcqyKYArF X-Talos-MUID: 9a23:teXltwsmm/BaZU57HM2npipYCpZE7q2VOVkovrhfvtfdDCVXNGLI X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,207,1787011200"; d="scan'208";a="532262569" Received: from rcdn-l-core-02.cisco.com ([173.37.255.139]) by rcdn-iport-2.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 11 Nov 2026 05:29:43 +0000 Received: from sjc-ads-5675.cisco.com (sjc-ads-5675.cisco.com [10.28.88.189]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by rcdn-l-core-02.cisco.com (Postfix) with ESMTPS id 654D018000211; Wed, 11 Nov 2026 05:29:43 +0000 (GMT) Received: by sjc-ads-5675.cisco.com (Postfix, from userid 1887444) id C751FCBEF98; Tue, 10 Nov 2026 21:29:42 -0800 (PST) From: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" To: openembedded-devel@lists.openembedded.org Cc: Darsh Kelaiya Subject: [oe][meta-python][wrynose][PATCH 8/9] python3-filelock: ignore CVE-2026-112910 Date: Tue, 10 Nov 2026 21:29:37 -0800 Message-Id: <20261111052938.3691463-9-dkelaiya@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261111052938.3691463-1-dkelaiya@cisco.com> References: <20261111052938.3691463-1-dkelaiya@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: ANONYMOUS;sjc-ads-5675.cisco.com [10.28.88.189];TLSv1.3;TLS_AES_256_GCM_SHA384;256 X-Outbound-SMTP-Client: 10.28.88.189, sjc-ads-5675.cisco.com X-Outbound-Node: rcdn-l-core-02.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Wed, 11 Nov 2026 05:29:50 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/132118 From: Darsh Kelaiya Analysis: - WindowsFileLock can ignore mode=0o600 and inherit the parent directory ACL instead of assigning owner-only permissions. [1][2] - The issue also covers the ReadWriteLock database on Windows; the upstream advisory explicitly excludes UnixFileLock. [1] - The Wrynose Linux target uses POSIX file permission handling and does not use the affected Windows security-descriptor code. [1][2] - Mark the CVE as not applicable to the Linux target platform. Reference: [1] https://github.com/tox-dev/filelock/security/advisories/GHSA-8mx5-7m5m-r77g [2] https://nvd.nist.gov/vuln/detail/CVE-2026-112910 Signed-off-by: Darsh Kelaiya --- meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb | 1 + 1 file changed, 1 insertion(+) diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb index 814fd59eb3..40c4fe9427 100644 --- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb +++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb @@ -8,6 +8,7 @@ LIC_FILES_CHKSUM = "file://LICENSE;md5=2c6acbdf7bb74caa37512c3a5ca6857b" CVE_PRODUCT = "tox-dev:filelock" CVE_STATUS[CVE-2026-112907] = "not-applicable-platform: Windows oplock handling is not used on Linux" +CVE_STATUS[CVE-2026-112910] = "not-applicable-platform: Windows ACL inheritance is affected; Linux uses POSIX file permissions" SRC_URI += " file://run-ptest \ file://CVE-2026-112939.patch \ From patchwork Wed Nov 11 05:29:38 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" X-Patchwork-Id: 103660 Received: from rcdn-iport-1.cisco.com (rcdn-iport-1.cisco.com [173.37.86.72]) by mx.groups.io with SMTP id smtpd.msgproc02-g2.3283.1794374983856874227 for ; Tue, 10 Nov 2026 21:29:44 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=IvP93J3x; spf=pass (domain: cisco.com, ip: 173.37.86.72, mailfrom: dkelaiya@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=1692; q=dns/txt; s=iport01; t=1794374984; x=1795584584; h=from:to:cc:subject:date:message-id:in-reply-to: references:mime-version:content-transfer-encoding; bh=KDYSz17B6S2htjGwkxrzV5iSWtA7cMdSi3GstFA9Kfk=; b=IvP93J3xR4ap4sbA8iXIqNV/F+o8DWnIhFCDsZCq6pzY/SpTkWTTBupG xQFCwMDsNzgm2xw7WHrO13l0RyNmpxaTSrnELgJZ8V9VICx5VG1h5wj1L RdhVpryhfa3RbS5gMo5Ey4mxT5Bq9zWo9Gvn5nbxvvxeMAPNRAV+Xiyxc mQxVOknPEtK+23b45sqZpNz61h8YeUcT9jQtcF/F0RiqPIKH9CQl1gTPt Gwy9z8KjxMzrIHihFgCpkEZp2+DiYIvx5dGf+6W+KfA+QVtf7/K5KFDdi ILL6/5Jh+H8rVNF6klGD+ipxXCoRRZ0bAe6YstNcpmuqdOGgclRb72Js9 A==; X-CSE-ConnectionGUID: /aTpxtcORgWK8Cu509qnCQ== X-CSE-MsgGUID: Qt3l+y9FRbuyQ8kzYQcKGg== X-IPAS-Result: A0BIAgBQ/PNq/5H/Ja1aHgEBCxIMggULgld1YENJA5ZIA4ETnQ8UgWoPAQEBD0QNBAEBhD9GAo4eAiY0CQ4BAgQDAgMBAQEBAQEBAQEBAQEKAQEFAQEBAgEHBYEOE4ZPDZATAgEDMgEYAS0QIBgZKx0BDRmDAgGCdAMRuViCLIEBgykBPwJDUNszAQsUAYE4hUKIJHUBhH0nGxuBcoEVgnN2gQWBXAEBgRmBBg6FVyIEgiKBDIJjjBGIdkiBHgNZLAFVEw0KCwcFgWgDNRIqFW4yHYEjPheBBxsGBYEcgR6BEoEHXYErKF0BIYEIgXYCglGCAAIBSUEOB0VTCSVDEkckIggSCQETGiwKgRo1PAkmPxUMZwsaBWEIAg0MdDkQMB4kLwUaJj4bCn4YDUgRLDcVGQQ+bgeSboMVgQ4BKwQcggyldqERCiiDeIwkg12RXRozqnILmH2OCpZRhGqBaDyBRwsHcBWDIglKGQ+OOYNrgX/MXCYyAgwvAQEHAgcOAwuBaJF+AQE IronPort-Data: A9a23:2FT0VKtqhmQOSmmGBht4WYlm0+fnVAZfMUV32f8akzHdYApBsoF/q tZmKWiAPfrcZGT9e9klb4Xg9BkFuJ+BzN8wQFBpriEwRiIbgMeUXt7xwmUckM+xwmwvaGo9s q3yv/GZdJhcokf0/0nrb/656yYsjclkf5KkYMbcICd9WAR4fykojBNnioYRj5Vh6TSDK1vlV eja/YuFZzdJ5xYuajhKsvva8Us21BjPkGpwUmIWNKgjUGD2zxH5PLpHTYmtIn3xRJVjH+LSb 47r0LGj82rFyAwmA9Wjn6yTWhVirmn6ZFXmZtJ+AsBOszAazsAA+v9T2Mk0NS+7vw60c+VZk 72hg3AfpTABZcUgkMxFO/VR/roX0aduoNcrKlDn2SCfItGvn3bEm51T4E8K0YIwve9LGXFS/ tsiJw8uPjue2rrn2bSQc7w57igjBJGD0II3oHpsy3TdSP0hW52GG/WM7t5D1zB2jcdLdRrcT 5NGMnw0M1KaPkAJYwtKYH49tL/Aan3Xfz5VrFuUtKMf6GnIxws327/oWDbQUoHWG54NwRjI+ goq+UzAHkoed83O5wCP72OV1uzdrBrwe60dQejQGvlCxQf7KnYoIBoOWF22pPO0hkKzV5dTJ lIZ/gIqrLMu7wqsVtT7UhiyrXKIsxJaXMBfe9DW8ymXwabSpgLcDW8eQ3sZOJottdQ9Qnoh0 Vrhc87VOAGDeYa9ERq1nop4ZxvrUcTJBQfuvRM5cDY= IronPort-HdrOrdr: A9a23:hQDxlql/hpY0oqy7UtKBSRbnLdXpDfIO3DAbv31ZSRFFG/FwWf rAoB19726StN9/YhAdcLy7VZVoBEmsl6KdgrNhWYtKIjOHhILAFugLhuHfKn/bakjDH4Vmu5 uIHZITNDSJNykYse/KpC+lDt0n3N6LtIqshevY0jNRaDsCUdAH0++8YTzranGfg2J9dOMEKK Y= X-Talos-CUID: 9a23:nh0bk2ozXHmbT88TcB+KLgzmUYcsTmXN0Hz3GUCHKjZCVeDNGEaW05oxxg== X-Talos-MUID: 9a23:sbrIdQX3+xUP8/Lq/Abw3zUhJZZG2a6zGXldkoc3gsKnGiMlbg== X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,207,1787011200"; d="scan'208";a="546307684" Received: from rcdn-l-core-08.cisco.com ([173.37.255.145]) by rcdn-iport-1.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 11 Nov 2026 05:29:43 +0000 Received: from sjc-ads-5675.cisco.com (sjc-ads-5675.cisco.com [10.28.88.189]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by rcdn-l-core-08.cisco.com (Postfix) with ESMTPS id 744E11800044B; Wed, 11 Nov 2026 05:29:43 +0000 (GMT) Received: by sjc-ads-5675.cisco.com (Postfix, from userid 1887444) id CE44ECBEFAB; Tue, 10 Nov 2026 21:29:42 -0800 (PST) From: "Darsh Kelaiya -X (dkelaiya - E INFOCHIPS PRIVATE LIMITED at Cisco)" To: openembedded-devel@lists.openembedded.org Cc: Darsh Kelaiya Subject: [oe][meta-python][wrynose][PATCH 9/9] python3-filelock: ignore CVE-2026-112913 Date: Tue, 10 Nov 2026 21:29:38 -0800 Message-Id: <20261111052938.3691463-10-dkelaiya@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261111052938.3691463-1-dkelaiya@cisco.com> References: <20261111052938.3691463-1-dkelaiya@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: ANONYMOUS;sjc-ads-5675.cisco.com [10.28.88.189];TLSv1.3;TLS_AES_256_GCM_SHA384;256 X-Outbound-SMTP-Client: 10.28.88.189, sjc-ads-5675.cisco.com X-Outbound-Node: rcdn-l-core-08.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Wed, 11 Nov 2026 05:29:50 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/132114 From: Darsh Kelaiya Analysis: - Upstream scopes this issue to Windows ReadWriteLock and AsyncReadWriteLock in directories writable by another account. [1] - Attacker-controlled SQLite side files can cause acquisition failures or unsafe hot-journal recovery on Windows. [1][2] - The advisory tracks the Linux/macOS issue separately; this series addresses that issue as CVE-2026-112940. [1][3] - Mark this Windows-only CVE as not applicable to the Linux target. Reference: [1] https://github.com/tox-dev/filelock/security/advisories/GHSA-9vhv-jxww-xfm5 [2] https://nvd.nist.gov/vuln/detail/CVE-2026-112913 [3] https://github.com/tox-dev/filelock/security/advisories/GHSA-v4x8-3wr5-3wq8 Signed-off-by: Darsh Kelaiya --- meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb | 1 + 1 file changed, 1 insertion(+) diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb index 40c4fe9427..9617d82b31 100644 --- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb +++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb @@ -9,6 +9,7 @@ CVE_PRODUCT = "tox-dev:filelock" CVE_STATUS[CVE-2026-112907] = "not-applicable-platform: Windows oplock handling is not used on Linux" CVE_STATUS[CVE-2026-112910] = "not-applicable-platform: Windows ACL inheritance is affected; Linux uses POSIX file permissions" +CVE_STATUS[CVE-2026-112913] = "not-applicable-platform: The CVE affects SQLite side-file handling on Windows only" SRC_URI += " file://run-ptest \ file://CVE-2026-112939.patch \