Skip to content
Open
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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,42 @@
All notable behavioural changes to `strands-robots` are logged here. Follows
[Keep a Changelog](https://keepachangelog.com/) conventions.

## Unreleased - #153 (Security dependency bumps)

### Changed

- **Pillow** floor raised from `>=8.0.0` to `>=10.3.0` (carries CVE-2024-28219
buffer-overflow fix for downstream consumers who do not use this repo's lockfile).
- **Pillow** ceiling raised from `<12.0.0` to `<13.0.0` (allows 12.2.0+).
- **pytest** ceiling raised from `<9.0.0` to `<10.0.0` (allows 9.0.3). Note:
pytest 9.x removed nose-style `setup`/`teardown` and changed `--strict`
aliasing; no impact on this repo's test suite.
- **pytest-cov** ceiling raised from `<6.0.0` to `<8.0.0` (allows 7.1.0).
- **Transitive lockfile** (`uv.lock`) regenerated with full extras matrix,
resolving cryptography 48.0.0 (crosses 2 major versions from prior lock at
46.0.6: 46->47->48; no deprecated API usage found in this repo),
python-multipart 0.0.28, gitpython 3.1.50, urllib3 2.7.0, and 42
previously-unresolved transitive packages (mujoco, transformers 5.8.1,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ### Security block below explicitly flags diffusers lockfile exposure, but the same kind of caveat applies inversely to cryptography / python-multipart / gitpython / urllib3 listed here: these are transitive-only and no [project.dependencies] or extras floor was added, so a pip install strands-robots consumer who doesn't use uv.lock can still resolve vulnerable versions.

Consider adding a sentence in the ### Security block clarifying that only Pillow's fix is constraint-level (and therefore wheel-install consumers are protected by the floor); for the transitive ones, only uv.lock users get the fix. As-is, the "19 of 20 alerts closed" framing is accurate for lockfile users but overstated for wheel-install users.

libero, robomimic, robosuite, numba, hydra-core, etc.).
- transformers 5.x: explicit compat code at
`strands_robots/policies/lerobot_local/policy.py:308-321`.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line-number reference (policy.py:308-321) is fragile — any reformat or new import above that range silently invalidates the citation, and the CHANGELOG is the one place in the repo that doesn't get an automated check for this. Suggest referencing the symbol instead, e.g. "LerobotLocalPolicy._load_model (XVLA / Florence2 compat shim)". AGENTS.md uses file_path:line_number shorthand for code references but those live in code review threads with finite shelf-life; CHANGELOGs persist across many rebases.

Non-blocking nit.

- The previous lockfile was generated against a partial extras set.
- The Pillow `>=10.3.0` floor is the minimum security guarantee;
the lockfile-pinned 12.2.0 is the recommended install path.

### Security

- Closes 19 of 20 open Dependabot alerts. Remaining: diffusers
GHSA-98h9-4798-4q5v (tracked in #210, blocked by lerobot 0.5.1
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Security block calls out diffusers exposure for lockfile users but does not call out the inverse exposure for wheel-install users on the transitive CVEs. The PR description states this clearly ("only lockfile (uv.lock) users get the fix. Wheel-install consumers (pip install strands-robots) are not protected by a constraint floor for these packages"). That sentence belongs in the CHANGELOG too -- a downstream user reading just CHANGELOG.md after pip install strands-robots upgrades cannot tell from the current text that they are still exposed to GHSA-p423-j2cm-9vmq (cryptography), GHSA-* (urllib3, gitpython, python-multipart) unless they switch to the lockfile install path.

cap `diffusers<0.36`). Lockfile users remain exposed until #210 lands;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diffusers GHSA exposure is documented but has no automated tripwire.

The entry says "Lockfile users remain exposed until #210 lands" — good disclosure. But there's no test or CI gate that fails (or even surfaces a notice) once lerobot relaxes its diffusers<0.36 cap. A reviewer six months from now will need to remember to check #210; a low-cost automated nudge is to add a parametrized entry to a _KNOWN_DEFERRED_VULNS table in tests/test_dependency_floors.py that asserts the relevant lerobot constraint is still <0.36 and prints a pytest.skip reason like "diffusers GHSA-98h9-4798-4q5v gated by lerobot<0.6 cap; revisit when #210 lands". Once lerobot bumps, the skip-reason changes and review attention follows. Optional, but cheap.

mitigation requires either floating diffusers above 0.38.0 or upgrading
lerobot once an upstream release relaxes the cap.
- **Wheel-install exposure note**: `pip install strands-robots` consumers
(without `uv.lock`) are protected only for the Pillow CVE (floor raised
to `>=10.3.0`). Transitive CVE fixes (cryptography, urllib3, gitpython,
python-multipart) have no direct-dep floor and are mitigated only by
lockfile regeneration.

## Unreleased - #178 (LiberoOffScreenRenderEngine retired)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per AGENTS.md > Project Dashboard rule ("never track work only in local markdown — the board is the source of truth"), this remaining alert should be filed as a tracked issue on Strands Labs - Robots and referenced here by issue number, e.g.

- Closes 19 of 20 open Dependabot alerts. Remaining: diffusers
  GHSA-98h9-4798-4q5v (tracked in #XXX, blocked by lerobot 0.5.1
  cap `diffusers<0.36`).

The PR description acknowledges this as "Known follow-up" but the issue itself isn't filed yet. Filing it before merge prevents the alert from going stale.


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing blank line between this ## Unreleased - #153 block and the ## Unreleased - #178 block immediately below. Keep-a-Changelog conventions (referenced at the top of the file) want one blank line between top-level sections. Also note line 5 / 6 introduces a double blank line above the new ## Unreleased - #153 heading — should be a single blank line.

### Removed: ``LiberoOffScreenRenderEngine`` simulation backend (BREAKING)
Expand Down
12 changes: 6 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ dependencies = [
"strands-agents>=1.0.0,<2.0.0",
"numpy>=1.21.0,<3.0.0",
"opencv-python-headless>=4.5.0,<5.0.0",
"Pillow>=8.0.0,<12.0.0",
"Pillow>=10.3.0,<13.0.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Floor bump to >=10.3.0 correctly carries CVE-2024-28219 for downstream pip install consumers — good R1 fix. One thing worth surfacing: Pillow 10.3.0 is the floor, not the resolved, so a consumer's resolver may still pin to 10.3.0 if another constraint pulls it down. Pillow has had subsequent CVE fixes in 10.4 / 11.x (e.g. GHSA-44wm-f244-xhp3 in 11.3.0). Consider documenting in the CHANGELOG that the floor is the minimum security guarantee and that the lockfile-pinned 12.2.0 is the recommended install path. This isn't blocking — it's a clarification so users don't read >=10.3.0 as "all known CVEs fixed".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per AGENTS.md > Review Learnings (#85) > "Pin regression tests for reviewed fixes": even though this is a constraint-only change, the security-floor guarantee for downstream pip install strands-robots consumers (the entire reason the floor was lifted from >=8.0.0 to >=10.3.0 in R1) has no test pinning it. A trivial tests/test_dependency_floors.py that parses this file with tomllib and asserts the Pillow floor is >=10.3.0 (and similarly for any future security-motivated floor) would prevent a routine uv lock --upgrade or constraint-cleanup PR from silently lowering it. The PR's review-rounds table waives this as "N/A — pure constraint change," but the floor is precisely the behavior R1 asked to harden, so it qualifies for a pin-test.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirming the lockstep with line 116 (hatch env). The new test_pin_site_consistency enforces this pairing for pillow, pytest, pytest-cov, which is exactly the foot-gun called out in AGENTS.md > Review Learnings (#86) under "Don't conflate identity with schema." Good.

No change requested — noting for the human reviewer that the duplicate-pin-site issue is acknowledged in the PR description as a known follow-up.

]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The uv.lock regeneration in this PR pulls in 42 new transitive packages that weren't in the previous lock (most notably transformers 5.8.1 - a major-version bump - plus mujoco, libero, robomimic, robosuite, numba, tensorboard, hydra-core, omegaconf). The previous uv.lock appears to have been resolved against a partial extras set (no sim-mujoco, no benchmark-libero, no mesh-iot); this PR's uv lock --upgrade re-resolves with the full matrix. transformers 5.x already has explicit compat code at strands_robots/policies/lerobot_local/policy.py:309-313, so the jump is probably fine, but it deserves a line in the PR body so reviewers know they're approving more than the 19 CVE-closing bumps.


[project.optional-dependencies]
Expand Down Expand Up @@ -84,8 +84,8 @@ all = [
"strands-robots[mesh-iot]",
]
dev = [
"pytest>=6.0,<9.0.0",
"pytest-cov>=4.0.0,<6.0.0",
"pytest>=6.0,<10.0.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest-cov>=4.0.0,<8.0.0 widens the ceiling by two major versions (skips both 6.x and 7.x major boundaries). AGENTS.md > Key Conventions #2 says >=1.0 deps cap major — i.e. one major above current. Since the resolved version is 7.1.0, <8.0.0 is correct, but the previous <6.0.0 to <8.0.0 jump effectively bypasses the convention's "one major at a time" intent. If 8.x compat is unverified (it doesn't exist yet), narrow to <8.0.0 is fine but please confirm that 6.x/7.x compatibility was actually exercised — easy to do by pinning pytest-cov==6.x.y in a one-shot resolve and rerunning hatch run test. Same comment applies to the duplicate at line 112.

"pytest-cov>=4.0.0,<8.0.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest 9.0.x was released only days ago; bumping the cap to <10.0.0 and pytest-cov to <8.0.0 simultaneously is a fine direction, but the resolved lock pins a brand-new pytest 9.0.3 + pytest-cov 7.1.0 pair that very few downstream projects have validated yet. Two suggestions: (a) note in the PR body that this constitutes a tooling jump (pytest 8->9 has removed/renamed several APIs - the nose-style setup/teardown, pytest.mark.skipif evaluation timing, and --strict aliasing changes) and surface it in CHANGELOG.md; (b) since the dev pins are duplicated between [project.optional-dependencies] dev (here) and [tool.hatch.envs.default.dependencies] (lines 111-112), consider following up by sourcing one from the other to prevent future drift - the current PR keeps them in sync correctly, but the duplication is a known foot-gun.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest 9.x major bump deserves a tests_integ/ smoke check.

The PR description correctly notes pytest 9 removed nose-style setup/teardown and changed --strict aliasing, and reports 2343 passed on the unit suite. That covers tests/, but [tool.hatch.envs.default.scripts].test-integ = "pytest tests_integ/ ..." (line 123) runs against a separate suite that the PR description doesn't mention exercising. If tests_integ/ uses any of the deprecated patterns, this PR will land green and break only on hardware/GPU runs.

Not a blocker — the bump is right — but worth confirming hatch run test-integ --collect-only still resolves cleanly before merge, and noting the result in the PR.

"ruff>=0.4.0,<1.0.0",
"mypy>=1.0.0,<2.0.0",
"pytest-timeout>=2.0.0,<3.0.0",
Expand All @@ -108,11 +108,11 @@ packages = ["strands_robots"]
installer = "uv"
features = ["all"]
dependencies = [
"pytest>=6.0,<9.0.0",
"pytest-cov>=4.0.0,<6.0.0",
"pytest>=6.0,<10.0.0",
"pytest-cov>=4.0.0,<8.0.0",
"ruff>=0.4.0,<1.0.0",
"mypy>=1.0.0,<2.0.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest cap bumped to <10.0.0 is consistent with the AGENTS.md >=1.0 cap-major convention, and pytest-cov 7.1.0 (the version uv.lock resolves) declares pytest>=7, so the combination is compatible.

One thing worth confirming before merge: pytest-timeout>=2.0.0,<3.0.0 (line 100) is not declared in [tool.hatch.envs.default].dependencies (lines 119-128), which means hatch resolves it transitively from the dev extra. With the pytest 8→9 bump, pytest-timeout 2.4.0 is what uv.lock resolves — please double-check that hatch run test-integ (which uses --timeout=300, line 132) still works on a clean env. The unit-test pass count in the PR description is reassuring but test-integ exercises the timeout path that unit tests don't.

"Pillow>=8.0.0,<12.0.0",
"Pillow>=10.3.0,<13.0.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the second of two Pillow>=10.3.0,<13.0.0 pin sites (the other is line 40). The PR description acknowledges consolidating these as a follow-up; flagging here only because the very next constraint-bump PR is the most likely place this drifts (someone updates [project.dependencies] and forgets [tool.hatch.envs.default], or vice versa). A 5-line tests/test_pin_site_consistency.py that asserts the floor/ceiling for Pillow, pytest, and pytest-cov matches between the two sites would close this risk without waiting for the consolidation PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description and AGENTS.md both mention the duplicate Pillow/pytest pin sites as a known follow-up to consolidate, but no tracking issue is linked. With the new test_multi_site_pins_consistent ratcheting drift in, every future bump now costs 2x edits per dep. Recommend filing a tracking issue on the project board (PVT_kwDOD151Fs4BSRJP) before this PR merges, so the consolidation work doesn't quietly age out -- AGENTS.md > Project Dashboard explicitly requires "the board is the source of truth" for follow-ups.

"requests>=2.28.0,<3.0.0",
"msgpack>=1.0.0,<2.0.0",
"pyzmq>=27.0.0,<28.0.0",
Expand Down
230 changes: 230 additions & 0 deletions tests/test_dependency_floors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
"""Repo hygiene: pin minimum security-floor versions and cross-site consistency.

These tests guard the dependency constraints in ``pyproject.toml`` so a routine
``uv lock --upgrade`` or constraint-cleanup PR cannot silently lower a floor
that was raised for a CVE, or let the two pin sites
(``[project.dependencies]`` / ``[project.optional-dependencies].dev`` /
``[tool.hatch.envs.default]``) drift out of sync.

Pinned floors (raise, never lower without an explicit security review):

- Pillow >= 10.3.0 -- carries CVE-2024-28219 buffer-overflow fix.
Raised in PR #153 R1 from the previous >=8.0.0 floor.

Why only Pillow here? Floors are only added when a CVE recurs in a *direct*
dep that downstream `pip install` consumers can resolve below the fixed
version. Transitive bumps (cryptography, urllib3, gitpython,
python-multipart, etc.) are mitigated by `uv.lock` regeneration -- they
have no direct constraint to pin.

Cross-site consistency: every dep that appears in more than one pin block must
have identical floor + ceiling everywhere it appears, otherwise a future
Dependabot PR can update one site and forget the other -- the exact pattern
that motivated the floor sweep in PR #153. The check is property-style
(scans every dep that appears in >=2 sites), so adding a new dual-site dep
does not require updating an allowlist.

Add an entry to ``_SECURITY_FLOORS`` whenever a floor is raised for a CVE
fix.
"""

from __future__ import annotations

import re
import tomllib
from pathlib import Path

import pytest
from packaging.version import Version

REPO_ROOT = Path(__file__).resolve().parent.parent
PYPROJECT = REPO_ROOT / "pyproject.toml"


# --------------------------------------------------------------------------- #
# Constraint parsing
# --------------------------------------------------------------------------- #

# Match e.g. ``Pillow>=10.3.0,<13.0.0``. Permissive on whitespace; case-insensitive
# on the package name (``Pillow`` vs ``pillow``).
_CONSTRAINT_RE = re.compile(
r"^\s*(?P<name>[A-Za-z0-9._-]+)\s*"
r"(?P<spec>(?:[<>=!~]=?\s*[A-Za-z0-9._*+-]+(?:\s*,\s*[<>=!~]=?\s*[A-Za-z0-9._*+-]+)*)?)\s*$"
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_CONSTRAINT_RE accepts the package name as [A-Za-z0-9._-]+ only, so it won't match a third-party spec with PEP 508 extras such as httpx[http2]>=0.27,<1.0. _parse_constraint will raise ValueError, and absorb() only swallows that error when the line starts with strands-robots[ — a future contributor adding any pkg-with-extras to pyproject.toml will get a confusing test failure rather than have the test cleanly index it.

No such dep exists today, so this isn't a current blocker. Cheap robustness fix: extend the name class to allow \[[^\]]*\] after the name and strip extras post-parse (name.split('[', 1)[0]), or generalize the special-case in absorb() to skip any <name>[...] self-style line. Either keeps the test from going off when an unrelated PR adds a pkg[extra] dep.



def _parse_constraint(entry: str) -> tuple[str, str]:
"""Return ``(canonical_name_lower, normalised_spec)``."""
m = _CONSTRAINT_RE.match(entry.split(";", 1)[0]) # drop env markers
if m is None:
raise ValueError(f"Could not parse constraint: {entry!r}")
name = m.group("name").lower().replace("_", "-")
spec = re.sub(r"\s+", "", m.group("spec"))
return name, spec


def _floor_of(spec: str) -> str | None:
"""Extract the ``>=`` lower bound from a spec like ``>=10.3.0,<13.0.0``.

Only ``>=`` is recognised. ``~=`` and ``==`` floors are intentionally not
accepted: ``_SECURITY_FLOORS`` entries must use ``>=`` so the pin clearly
states the minimum-acceptable version. If a future dep needs a different
operator, broaden this helper at the same time as the floor is added.
"""
m = re.search(r">=\s*([A-Za-z0-9._*+-]+)", spec)
return m.group(1) if m else None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_floor_of only recognises >=. If a future floor uses ~=10.3.0 (compatible-release) or ==10.3.*, this returns None and the assertion at line 155-157 fires with a misleading "no >= floor" message even though the spec semantically does enforce a floor.

Not urgent (no current dep uses ~=), but consider broadening the regex to also accept ~= and == floors, or at minimum document in the docstring that _SECURITY_FLOORS entries must use a >= constraint.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_floor_of deliberately rejects ~= and == (per the docstring), but the rejection mode is a silent None return and the assertion at line 177 just says cannot enforce CVE floor. If anyone ever switches Pillow to Pillow~=10.3 on the (mistaken) belief that ~= implies a security floor, the test fails with a message that doesn't tell them which operator is the problem.

One extra clause in the helper or assertion message (...use the >= operator for security floors; ~= and == are not recognised) would convert that into a self-documenting failure. Strictly cosmetic — the existing message is enough to grep _floor_of and find the docstring — but cheap to add now.



def _parse_version(v: str) -> Version:
"""Parse a version string using PEP 440 semantics.

Uses ``packaging.version.Version`` for correct handling of pre-releases,
post-releases, dev-releases, and local versions. This ensures that a
floor like ``>=10.3.0`` correctly rejects ``10.3.0rc1`` (which is
*before* the release and may not contain the CVE fix).
"""
return Version(v)


# --------------------------------------------------------------------------- #
# Fixtures
# --------------------------------------------------------------------------- #


@pytest.fixture(scope="module")
def pyproject() -> dict:
with PYPROJECT.open("rb") as fh:
return tomllib.load(fh)


@pytest.fixture(scope="module")
def constraint_index(pyproject: dict) -> dict[str, dict[str, str]]:
"""Map ``dep_name -> {site: spec}`` across every pin site in pyproject.

Sites tracked:
- ``project.dependencies``
- ``project.optional-dependencies.<extra>`` for every declared extra
- ``tool.hatch.envs.default.dependencies``

Self-references with extras (``"strands-robots[mesh]"``) have no spec and
are intentionally skipped by ``_parse_constraint`` raising ``ValueError``;
a real parse failure on a typo'd spec re-raises so the test surfaces the
bug instead of silently dropping the entry.
"""
index: dict[str, dict[str, str]] = {}

def absorb(site: str, entries: list[str]) -> None:
for raw in entries:
try:
name, spec = _parse_constraint(raw)
except ValueError:
# Self-reference like "strands-robots[mesh]" has no spec;
# let any other parse failure propagate so a typo doesn't
# silently disappear.
if raw.lstrip().startswith("strands-robots["):
continue
raise
index.setdefault(name, {})[site] = spec

project = pyproject.get("project", {})
absorb("project.dependencies", project.get("dependencies", []) or [])
for extra, entries in (project.get("optional-dependencies", {}) or {}).items():
absorb(f"project.optional-dependencies.{extra}", entries or [])

hatch_default = (
pyproject.get("tool", {}).get("hatch", {}).get("envs", {}).get("default", {}).get("dependencies", []) or []
)
absorb("tool.hatch.envs.default.dependencies", hatch_default)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope of the multi-site scan is tool.hatch.envs.default.dependencies only. If a future PR adds a sibling env (e.g. [tool.hatch.envs.docs.dependencies] or [tool.hatch.envs.ci.dependencies]), drift in those sites will not be caught -- the property-style check silently downgrades to a 2-site check.

Minor today (only default exists), but the intent of this test is to be a permanent ratchet, so iterating over pyproject["tool"]["hatch"]["envs"] and absorbing every env's dependencies would future-proof it against the exact "forgot the second site" foot-gun the test exists to prevent. Either fix it now or add a comment that explicitly limits scope to default.


return index


# --------------------------------------------------------------------------- #
# Security floors -- raise, never silently lower
# --------------------------------------------------------------------------- #

# (canonical_name, minimum_floor, rationale)
_SECURITY_FLOORS: list[tuple[str, str, str]] = [
(
"pillow",
"10.3.0",
"CVE-2024-28219 buffer-overflow fix; raised in PR #153 R1.",
),
]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only Pillow is enumerated here, but the PR's stated security wins also include transitive bumps for cryptography, python-multipart, gitpython, urllib3 — none of which have a direct constraint to pin. That's correct behaviour for this PR, but worth a short comment in this list explaining the policy: floors only get added when a CVE recurs in a direct dep, otherwise lockfile regeneration is the mitigation. Otherwise the next reviewer will reasonably ask "why isn't cryptography here?"



@pytest.mark.parametrize(("name", "min_floor", "rationale"), _SECURITY_FLOORS)
def test_security_floor_not_lowered(
constraint_index: dict[str, dict[str, str]],
name: str,
min_floor: str,
rationale: str,
) -> None:
"""Every pin site that declares ``name`` must enforce ``>=min_floor``.

Lowering a security floor requires deleting the entry from
``_SECURITY_FLOORS`` with a reviewer-visible explanation, so the change
surfaces in PR review rather than slipping through a silent ``uv lock``.
"""
sites = constraint_index.get(name)
assert sites, (
f"{name} not found in any pyproject pin site -- floor pin is dead. "
f"Either add it back or remove from _SECURITY_FLOORS. ({rationale})"
)

for site, spec in sites.items():
floor = _floor_of(spec)
assert floor is not None, (
f"{name} in {site} has no >= floor (spec={spec!r}); cannot enforce CVE floor. {rationale}"
)
assert _parse_version(floor) >= _parse_version(min_floor), (
f"{name} floor regression in {site}: spec={spec!r} "
f"declares >={floor} but security minimum is >={min_floor}. "
f"{rationale}"
)


# --------------------------------------------------------------------------- #
# Multi-site consistency -- pinned in multiple blocks must match exactly
# --------------------------------------------------------------------------- #


def test_multi_site_pins_consistent(constraint_index: dict[str, dict[str, str]]) -> None:
"""Every dep declared in 2+ pin sites must carry an identical spec.

Catches the foot-gun where a Dependabot PR updates
``[project.optional-dependencies].dev`` but forgets
``[tool.hatch.envs.default]`` (or vice versa), letting the hatch env
silently resolve a different version than the wheel install.

Property-style: scans every dep automatically. Adding a new dual-site
dep does not require updating an allowlist; the test catches drift on
every dep that lives in 2+ sites today and any added tomorrow.
"""
drifted = {
name: sites for name, sites in constraint_index.items() if len(sites) >= 2 and len(set(sites.values())) > 1
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drift detection compares specs as strings (after whitespace strip), so semantically-equivalent specs that differ only in operator order will false-positive. Example future Dependabot PR landing:

Pillow<13.0.0,>=10.3.0   # in [project.dependencies]
Pillow>=10.3.0,<13.0.0   # in [tool.hatch.envs.default]

would flag as drift even though pip/uv treat them identically. Today every entry uses floor-then-ceiling order so this is latent, but the test's stated property is "identical spec everywhere it appears". A more robust check parses each spec to a packaging.specifiers.SpecifierSet and compares those, giving semantic equality for free — and packaging is already imported in this file.

Not blocking; flagging so the next reader doesn't waste an afternoon debugging a false-drift failure.

}
assert not drifted, (
f"Multi-site pins disagree -- update every site to the same constraint when bumping. Drift: {drifted}"
)


# --------------------------------------------------------------------------- #
# PEP 440 pre-release correctness (regression pin for R5 fix)
# --------------------------------------------------------------------------- #


def test_parse_version_rejects_prerelease_below_release() -> None:
"""Verify that _parse_version correctly identifies pre-releases as below their release.

Regression pin: before R5, ``_version_tuple("10.3.0rc1")`` returned ``(10, 3, 0)``
which compared equal to ``_version_tuple("10.3.0")``, silently accepting a
pre-release floor that might not carry the CVE fix. With ``packaging.version.Version``,
``10.3.0rc1 < 10.3.0`` is correctly enforced.
"""
assert _parse_version("10.3.0rc1") < _parse_version("10.3.0")
assert _parse_version("10.3.0") >= _parse_version("10.3.0")
assert _parse_version("10.3") == _parse_version("10.3.0")
assert _parse_version("10.4.0") > _parse_version("10.3.0")
assert _parse_version("10.3.0.post1") > _parse_version("10.3.0")
assert _parse_version("10.3.0.dev1") < _parse_version("10.3.0")
Loading
Loading