diff --git a/requirements.txt b/requirements.txt index 9aedaab..c1d2b4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,13 @@ -PySide6>=6.11.1 +# Temporary ceiling for the 1.15.1 release. PySide6 6.12.0 (2026-10-08) +# moved WebEngine and Pdf into wheels of their own; 6.11 already shipped +# both inside PySide6_Addons. A Windows PyInstaller build barely changes +# (PDFApps.exe +0.54%), but the frozen macOS and Linux builds, the snap +# and the AppImage have not been tested with 6.12. Remove the ceiling, +# together with tests/test_pyside6_ceiling.py, once the frozen Windows, +# macOS and Linux builds, the snap and (while it ships) the AppImage +# all pass the smoke test with 6.12. Until then, close by hand any +# Dependabot PR that widens it. +PySide6>=6.11.1,<6.12 # PYSEC-2026-3655 / PYSEC-2026-3656 (fixed in 6.15.0), CVE-2026-82398 # (6.15.0), CVE-2026-84309 (6.16.0) and CVE-2026-84310 / CVE-2026-84311 # (6.16.1). Then PYSEC-2026-4158 (6.17.0), PYSEC-2026-4153 (6.18.0), @@ -11,10 +20,10 @@ PySide6>=6.11.1 # floor, so a floor that admits vulnerable releases audits clean for as # long as a fixed release exists. # tests/test_dependency_security_floors.py fails if the floor drops -# below 6.19.0, but only where pytest runs (as of 2026-10-05 no -# workflow on main runs pytest). An advisory published later against -# the floor turns nothing that audits this file red once a fixed -# release exists. Do not lower it. +# below 6.19.0, wherever pytest runs; tests.yml runs it on every pull +# request to main and every push to main. An advisory published later +# against the floor turns nothing that audits this file red once a +# fixed release exists. Do not lower it. pypdf>=6.19.0 # GHSA-537c-gmf6-5ccf: signature verification bypass in legacy OpenSSL # providers fixed in 48.0.1, and PYSEC-2026-3552 / CVE-2026-69247 diff --git a/tests/_requirements_parsing.py b/tests/_requirements_parsing.py new file mode 100644 index 0000000..f080940 --- /dev/null +++ b/tests/_requirements_parsing.py @@ -0,0 +1,66 @@ +"""requirements.txt parsing shared by the dependency tests. + +Not a test module: pytest does not collect it because its name matches +neither ``test_*.py`` nor ``*_test.py``, the default ``python_files`` +patterns. The tests import it by bare name, which works because +``tests/`` has no ``__init__.py``, so pytest's default import mode +puts ``tests/`` on ``sys.path``. +""" + +from __future__ import annotations + +import re + +from packaging.requirements import Requirement +from packaging.specifiers import Specifier, SpecifierSet +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +# pip strips "#" to end of line when it starts the line or follows +# whitespace; "pypdf>=6.19.0 # note" is a valid requirements line. +INLINE_COMMENT = re.compile(r"(^|\s)#.*$") + + +def parse_line(line: str) -> Requirement | None: + s = INLINE_COMMENT.sub("", line).strip() + return Requirement(s) if s else None + + +def parse_requirements_text(text: str) -> dict[str, Requirement]: + """Map PEP 503 name -> Requirement, failing on a repeated name.""" + out: dict[str, Requirement] = {} + for line in text.splitlines(): + req = parse_line(line) + if req is None: + continue + name = canonicalize_name(req.name) + assert name not in out, f"{req.name} listed twice" + out[name] = req + return out + + +def lowest_admitted(clause: Specifier) -> Version | None: + """Lowest version ``clause`` can admit, or None if it has no floor. + + ``==X.*`` admits X's dev and pre-releases, which sort below X, so its + floor is ``X.dev0``. Every other bounding operator admits nothing + below its own version (``>X`` admits nothing at or below X). + """ + if clause.operator in ("<", "<=", "!="): + return None + ver = clause.version + if ver.endswith(".*"): + ver = ver[:-2] + ".dev0" + try: + return Version(ver) + except InvalidVersion: + return None # "===" with a non-PEP 440 string + + +def floor_of(spec: SpecifierSet) -> Version | None: + """The highest clause floor in ``spec``, or None if no clause has one. + + Clauses are ANDed, so the highest floor is the one that binds. + """ + floors = [f for f in map(lowest_admitted, spec) if f is not None] + return max(floors) if floors else None diff --git a/tests/test_dependency_security_floors.py b/tests/test_dependency_security_floors.py index 1be28e5..e90e532 100644 --- a/tests/test_dependency_security_floors.py +++ b/tests/test_dependency_security_floors.py @@ -19,14 +19,15 @@ from __future__ import annotations -import re from pathlib import Path import pytest +from _requirements_parsing import lowest_admitted as _lowest_admitted +from _requirements_parsing import parse_line as _parse_line +from _requirements_parsing import parse_requirements_text from packaging.requirements import Requirement -from packaging.specifiers import Specifier, SpecifierSet -from packaging.utils import canonicalize_name -from packaging.version import InvalidVersion, Version +from packaging.specifiers import SpecifierSet +from packaging.version import Version ROOT = Path(__file__).resolve().parent.parent REQUIREMENTS = ROOT / "requirements.txt" @@ -51,46 +52,11 @@ "idna": ("3.15", ("3.13", "3.14")), } -# pip strips "#" to end of line when it starts the line or follows -# whitespace; "pypdf>=6.19.0 # note" is a valid requirements line. -_INLINE_COMMENT = re.compile(r"(^|\s)#.*$") - - -def _parse_line(line: str) -> Requirement | None: - s = _INLINE_COMMENT.sub("", line).strip() - return Requirement(s) if s else None - def _requirements(text: str | None = None) -> dict[str, Requirement]: if text is None: text = REQUIREMENTS.read_text(encoding="utf-8") - out: dict[str, Requirement] = {} - for line in text.splitlines(): - req = _parse_line(line) - if req is None: - continue - name = canonicalize_name(req.name) - assert name not in out, f"{req.name} listed twice" - out[name] = req - return out - - -def _lowest_admitted(clause: Specifier) -> Version | None: - """Lowest version ``clause`` can admit, or None if it has no floor. - - ``==X.*`` admits X's dev and pre-releases, which sort below X, so its - floor is ``X.dev0``. Every other bounding operator admits nothing - below its own version (``>X`` admits nothing at or below X). - """ - if clause.operator in ("<", "<=", "!="): - return None - ver = clause.version - if ver.endswith(".*"): - ver = ver[:-2] + ".dev0" - try: - return Version(ver) - except InvalidVersion: - return None # "===" with a non-PEP 440 string + return parse_requirements_text(text) def _rejects_everything_below(spec: SpecifierSet, minimum: Version) -> bool: diff --git a/tests/test_flatpak_dependency_pins.py b/tests/test_flatpak_dependency_pins.py index debb276..9720ed4 100644 --- a/tests/test_flatpak_dependency_pins.py +++ b/tests/test_flatpak_dependency_pins.py @@ -19,6 +19,8 @@ from pathlib import Path import pytest +from _requirements_parsing import floor_of, parse_requirements_text +from packaging.version import Version try: import yaml @@ -136,30 +138,98 @@ def test_build_command_installs_every_pinned_package(): ) +def _stale_pins(root_text: str, pin_text: str) -> list[str]: + """Flatpak pins below their requirements.txt floor, one line each. + + Fail-closed: a root line this check cannot judge (an environment + marker, no floor at all) or a pin that is not an exact ``==`` is + reported too. Skipping such lines would be fail-open: a floor raised + as ``pypdf>=6.99.0 # note`` would then let a 6.19.0 pin pass. + """ + root = parse_requirements_text(root_text) + problems = [] + for name, pin in parse_requirements_text(pin_text).items(): + req = root.get(name) + if req is None: + continue # e.g. shiboken6, a PySide6 transitive not pinned at root + if req.marker is not None: + problems.append( + f"{name}: requirements.txt '{req}' carries an environment " + "marker, so its floor does not hold everywhere" + ) + continue + floor = floor_of(req.specifier) + if floor is None: + problems.append(f"{name}: requirements.txt '{req}' has no floor") + continue + clauses = list(pin.specifier) + if ( + len(clauses) != 1 + or clauses[0].operator != "==" + or clauses[0].version.endswith(".*") + ): + problems.append(f"{name}: flatpak pin '{pin}' is not an exact '==' pin") + continue + got = Version(clauses[0].version) + if got < floor: + problems.append(f"{name}: flatpak pin {got} < requirements floor {floor}") + return problems + + +_SAMPLE_PINS = "PySide6==6.11.1\nshiboken6==6.11.1\npypdf==6.19.0\n" + + +@pytest.mark.parametrize( + ("root_line", "expected"), + [ + ("pypdf>=6.99.0 # note", "< requirements floor 6.99.0"), + ('pypdf>=6.99.0; sys_platform != "emscripten"', "environment marker"), + ("PySide6~=6.11.2", "< requirements floor 6.11.2"), + ("PySide6<6.12,>=6.11.2", "< requirements floor 6.11.2"), + ("PySide6>=6.11.1,<6.12 # cap", None), + ("pypdf>=6.19.0", None), + ("pypdf>=6.19.1", "< requirements floor 6.19.1"), + ("pypdf>=6.0,>=6.99.0", "< requirements floor 6.99.0"), + ("PySide6<6.12", "has no floor"), + ("pypdf", "has no floor"), + ], +) +def test_stale_pins_flags_stale_and_unjudgeable_root_lines(root_line, expected): + problems = _stale_pins(root_line, _SAMPLE_PINS) + if expected is None: + assert problems == [] + else: + assert len(problems) == 1 and expected in problems[0], problems + + +@pytest.mark.parametrize("pin", [">=6.19.0", "==6.19.*", "", "==6.19.0,<7"]) +def test_stale_pins_rejects_a_pin_that_is_not_exact(pin): + problems = _stale_pins("pypdf>=6.19.0", f"pypdf{pin}") + assert len(problems) == 1 and "not an exact" in problems[0], problems + + +@pytest.mark.parametrize("ceiling", ["", ",<6.12"]) +def test_stale_pins_accepts_the_real_files_with_and_without_a_ceiling(ceiling): + real = REQUIREMENTS.read_text(encoding="utf-8") + floor = floor_of(parse_requirements_text(real)["pyside6"].specifier) + root_text, n = re.subn( + r"(?m)^PySide6\b.*$", f"PySide6>={floor}{ceiling}", real + ) + assert n == 1 + assert _stale_pins(root_text, PINNED.read_text(encoding="utf-8")) == [] + + def test_flatpak_pins_satisfy_the_root_requirements_floor(): """Every Flatpak pin must be >= the floor in requirements.txt. Without this, a security bump to requirements.txt can land while the Flatpak keeps installing the vulnerable version. """ - root = _parse_requirements(REQUIREMENTS) - pinned = _parse_requirements(PINNED) - - def parts(v: str) -> tuple[int, ...]: - return tuple(int(x) for x in re.findall(r"\d+", v)[:4]) - - stale = [] - for name, spec in pinned.items(): - floor_spec = root.get(name) - if floor_spec is None: - continue # e.g. shiboken6, a PySide6 transitive not pinned at root - m = re.fullmatch(r">=\s*(.+)", floor_spec) - if not m: - continue - want, got = parts(m.group(1)), parts(spec.lstrip("= ")) - if got < want: - stale.append(f"{name}: flatpak pin {spec.lstrip('= ')} < requirements floor {m.group(1)}") - assert not stale, "\n".join(stale) + problems = _stale_pins( + REQUIREMENTS.read_text(encoding="utf-8"), + PINNED.read_text(encoding="utf-8"), + ) + assert not problems, "\n".join(problems) def test_no_root_dependency_is_silently_dropped_from_the_flatpak(): diff --git a/tests/test_pyside6_ceiling.py b/tests/test_pyside6_ceiling.py new file mode 100644 index 0000000..eeafaf2 --- /dev/null +++ b/tests/test_pyside6_ceiling.py @@ -0,0 +1,75 @@ +"""requirements.txt must keep PySide6 below 6.12 while the ceiling lasts. + +The ceiling is temporary, for the 1.15.1 release: the frozen macOS and +Linux builds, the snap and the AppImage have not been tested with +PySide6 6.12. It goes once the frozen Windows, macOS and Linux builds, +the snap and (while it ships) the AppImage all pass the smoke test +with 6.12. This test exists only to stop the ceiling being widened or +dropped by accident (a Dependabot PR, a hand edit), and it is removed +together with the ceiling. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from _requirements_parsing import parse_line, parse_requirements_text +from packaging.requirements import Requirement + +REQUIREMENTS = Path(__file__).resolve().parent.parent / "requirements.txt" + +# Checked with prereleases=True, so a 6.12 pre-release counts as admitted +# whatever the packaging release: before packaging 26.0, contains() with +# the default rejected a pre-release unless the specifier named one. +_BEYOND_CEILING = ("6.12.0rc1", "6.12.0.dev0", "6.12.0", "6.12.1", "6.13.0", "7.0.0") +# A ceiling must not shut out the 6.11 series the app is built on; a +# floor raised within 6.11 is fine, so any 6.11.x will do. +_SERIES_6_11 = tuple(f"6.11.{i}" for i in range(100)) + + +def _ceiling_problems(req: Requirement) -> list[str]: + problems = [] + if req.marker is not None: + problems.append(f"'{req}' carries an environment marker") + admitted = [ + v for v in _BEYOND_CEILING if req.specifier.contains(v, prereleases=True) + ] + if admitted: + problems.append(f"'{req}' admits {admitted}") + if not any(req.specifier.contains(v) for v in _SERIES_6_11): + problems.append(f"'{req}' admits no 6.11.x release") + return problems + + +@pytest.mark.parametrize( + ("line", "ok"), + [ + ("PySide6>=6.11.1,<6.12", True), + ("PySide6>=6.11.2,<6.12", True), + ("PySide6>=6.11.1,<6.12 # cap", True), + ("PySide6~=6.11.1", True), + ("PySide6>=6.11.1", False), + ("PySide6>=6.11.1,<6.13", False), + ("PySide6>=6.11.1,!=6.12.0", False), + ("PySide6>=6.11.1,<=6.12.0rc1", False), + ("PySide6>=6.11.1,<6.12.0.dev1", False), + ("PySide6>=6.11.1,!=6.12.0,!=6.12.1,<6.13", False), # 6.12.0rc1 + ("PySide6<6.11", False), + ("PySide6>=6.12", False), + ('PySide6>=6.11.1,<6.12; sys_platform == "win32"', False), + ], +) +def test_ceiling_check_accepts_6_11_only_specifiers(line, ok): + assert (_ceiling_problems(parse_line(line)) == []) is ok + + +def test_requirements_keep_pyside6_below_6_12(): + req = parse_requirements_text(REQUIREMENTS.read_text(encoding="utf-8"))["pyside6"] + problems = _ceiling_problems(req) + assert not problems, ( + "requirements.txt must keep PySide6 below 6.12 until the " + "frozen Windows, macOS and Linux builds, the snap and (while " + "it ships) the AppImage all pass the smoke test with 6.12: " + + "; ".join(problems) + )