Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions tests/_requirements_parsing.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 6 additions & 40 deletions tests/test_dependency_security_floors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down
106 changes: 88 additions & 18 deletions tests/test_flatpak_dependency_pins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down
75 changes: 75 additions & 0 deletions tests/test_pyside6_ceiling.py
Original file line number Diff line number Diff line change
@@ -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)
)
Loading