diff --git a/docs/how-tos/release-age-cooldown.rst b/docs/how-tos/release-age-cooldown.rst index 7d7d0c8c..366a5d32 100644 --- a/docs/how-tos/release-age-cooldown.rst +++ b/docs/how-tos/release-age-cooldown.rst @@ -130,3 +130,17 @@ Valid values: This is useful when a specific package is trusted enough to allow recent versions, or when a package's release cadence makes the global cooldown impractical. + +Top-Level ``==`` Pin Exemption +------------------------------ + +Top-level requirements that are a single exact ``==`` pin +(e.g. ``torch==2.5.1``) bypass the cooldown automatically -- the operator +has explicitly chosen that version. + +Wildcard or compound specifiers (for example ``==1.*`` or ``==1.0,>0.9``) +are **not** exempt. + +``==`` specifiers in transitive dependencies are also **not** exempt; +without this distinction a malicious package could pin its own dependencies +to bypass cooldown. diff --git a/docs/proposals/release-cooldown.md b/docs/proposals/release-cooldown.md index fbb826d7..875242e2 100644 --- a/docs/proposals/release-cooldown.md +++ b/docs/proposals/release-cooldown.md @@ -55,17 +55,13 @@ References: global default plus per-package overrides. - **SSH transport** for git timestamp retrieval. -### Future consideration: `==` pin exemptions - -Whether `==` pins in top-level requirements or constraints files -should automatically bypass cooldown is deferred. The per-package -`resolver_dist.min_release_age: 0` override already provides an -explicit, auditable escape hatch for packages that need to use -recently-published versions. Adding automatic `==` exemptions -would introduce a special case that weakens the security model -and requires users to understand the distinction. This can be -revisited if the per-package override proves too cumbersome in -practice. +### `==` pin exemptions (implemented) + +Top-level single exact `==` pins (e.g. `torch==2.5.1`) bypass cooldown +automatically. Wildcard (`==1.*`) and compound specifiers are not exempt. +Transitive `==` pins remain subject to cooldown for security. See +[the how-to guide](../how-tos/release-age-cooldown.rst) for details. +Tracked in [#1123](https://github.com/python-wheel-build/fromager/issues/1123). ## How diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index d482d427..0989b9be 100644 --- a/src/fromager/resolver.py +++ b/src/fromager/resolver.py @@ -103,7 +103,7 @@ def resolve( req_type=req_type, ignore_platform=ignore_platform, ) - provider.cooldown = resolve_package_cooldown(ctx, req) + provider.cooldown = resolve_package_cooldown(ctx, req, req_type=req_type) max_age_cutoff = _compute_max_age_cutoff(ctx) results = find_all_matching_from_provider( provider, req, max_age_cutoff=max_age_cutoff @@ -137,19 +137,36 @@ def default_resolver_provider( ) +def _has_equality_pin(req: Requirement) -> bool: + """Return True if the requirement has a single exact ``==`` pin. + + Rejects wildcard pins (``==1.*``) and compound specifiers (``==1,>2``) + which are not true exact version pins. + """ + specs = list(req.specifier) + return len(specs) == 1 and specs[0].operator == "==" and "*" not in specs[0].version + + def resolve_package_cooldown( ctx: context.WorkContext, req: Requirement, + req_type: RequirementType | None = None, ) -> Cooldown | None: """Compute the effective cooldown for a single package. Args: ctx: The current work context (provides the global cooldown). req: The package requirement being resolved. + req_type: The requirement type (top-level, install, etc.). Returns: The cooldown to pass to the provider, or ``None`` if disabled. """ + if req_type == RequirementType.TOP_LEVEL and _has_equality_pin(req): + if ctx.cooldown is not None: + logger.info("cooldown bypassed as the top-level requirement uses == pin") + return None + per_package_days = ctx.package_build_info(req).resolver_min_release_age global_cooldown = ctx.cooldown if per_package_days is None: diff --git a/src/fromager/sources.py b/src/fromager/sources.py index 094ffef7..996eb333 100644 --- a/src/fromager/sources.py +++ b/src/fromager/sources.py @@ -153,7 +153,7 @@ def get_source_provider( ignore_platform=pbi.resolver_ignore_platform, ), ) - provider.cooldown = resolver.resolve_package_cooldown(ctx, req) + provider.cooldown = resolver.resolve_package_cooldown(ctx, req, req_type=req_type) return provider diff --git a/src/fromager/wheels.py b/src/fromager/wheels.py index f82042d2..0998a9de 100644 --- a/src/fromager/wheels.py +++ b/src/fromager/wheels.py @@ -549,7 +549,9 @@ def resolve_all_prebuilt_wheels( provider = get_prebuilt_wheel_provider( ctx=ctx, req=req, wheel_server_url=url, req_type=req_type ) - provider.cooldown = resolver.resolve_package_cooldown(ctx, req) + provider.cooldown = resolver.resolve_package_cooldown( + ctx, req, req_type=req_type + ) # The local fromager wheel server is PEP 503-only and serves # packages that were already resolved and vetted earlier in the # same run. Don't fail-closed on missing upload_time there. diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py index 6add8564..aa27916d 100644 --- a/tests/test_cooldown.py +++ b/tests/test_cooldown.py @@ -20,6 +20,7 @@ from packaging.version import Version from fromager import candidate, context, packagesettings, resolver, sources, wheels +from fromager.requirements_file import RequirementType _BOOTSTRAP_TIME = datetime.datetime(2026, 3, 26, 0, 0, 0, tzinfo=datetime.UTC) _COOLDOWN_7_DAYS = datetime.timedelta(days=7) @@ -247,6 +248,43 @@ def test_cooldown_applied_automatically_via_ctx(tmp_path: pathlib.Path) -> None: assert str(version) == "1.3.2" +def test_toplevel_equality_pin_bypasses_cooldown_via_resolve( + tmp_path: pathlib.Path, +) -> None: + """Top-level == pin threads through resolve() and bypasses cooldown end-to-end. + + Verifies the req_type plumbing in resolver.resolve() actually causes + resolve_package_cooldown() to disable cooldown, allowing a recent version + that would normally be filtered. + """ + ctx = context.WorkContext( + active_settings=None, + constraints_file=None, + patches_dir=tmp_path / "patches", + sdists_repo=tmp_path / "sdists-repo", + wheels_repo=tmp_path / "wheels-repo", + work_dir=tmp_path / "work-dir", + cooldown=_COOLDOWN, + ) + + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=_cooldown_json_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + + _, version = resolver.resolve( + ctx=ctx, + req=Requirement("test-pkg==2.0.0"), + sdist_server_url="https://pypi.org/simple/", + include_sdists=True, + include_wheels=True, + req_type=RequirementType.TOP_LEVEL, + ) + assert str(version) == "2.0.0" + + def test_cooldown_applied_via_get_source_provider(tmp_path: pathlib.Path) -> None: """ctx.cooldown propagates through sources.get_source_provider() to any provider. @@ -860,3 +898,69 @@ def test_compute_max_age_cutoff_disabled( """_compute_max_age_cutoff returns None when max_release_age is not set.""" cutoff = resolver._compute_max_age_cutoff(tmp_context) assert cutoff is None + + +def test_resolve_package_cooldown_exempt_toplevel_equality_pin( + tmp_path: pathlib.Path, +) -> None: + """Top-level == pin bypasses cooldown.""" + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg==1.3.2"), req_type=RequirementType.TOP_LEVEL + ) + assert result is None + + +def test_resolve_package_cooldown_enforced_transitive_equality_pin( + tmp_path: pathlib.Path, +) -> None: + """Transitive == pin does NOT bypass cooldown.""" + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg==1.3.2"), req_type=RequirementType.INSTALL + ) + assert result is _COOLDOWN + + +def test_resolve_package_cooldown_enforced_toplevel_no_pin( + tmp_path: pathlib.Path, +) -> None: + """Top-level requirement without == still gets cooldown.""" + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg>=1.0"), req_type=RequirementType.TOP_LEVEL + ) + assert result is _COOLDOWN + + +def test_resolve_package_cooldown_none_req_type_not_exempt( + tmp_path: pathlib.Path, +) -> None: + """Unknown req_type (None) with == does NOT bypass cooldown.""" + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg==1.3.2"), req_type=None + ) + assert result is _COOLDOWN + + +def test_resolve_package_cooldown_toplevel_wildcard_equality_not_exempt( + tmp_path: pathlib.Path, +) -> None: + """Top-level wildcard equality (==1.*) is not an exact pin — cooldown applies.""" + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg==1.*"), req_type=RequirementType.TOP_LEVEL + ) + assert result is _COOLDOWN + + +def test_resolve_package_cooldown_toplevel_compound_specifier_not_exempt( + tmp_path: pathlib.Path, +) -> None: + """Top-level compound specifier (==1.0,>0.9) is not a single exact pin.""" + ctx = _make_ctx(tmp_path, cooldown=_COOLDOWN) + result = resolver.resolve_package_cooldown( + ctx, Requirement("test-pkg==1.0,>0.9"), req_type=RequirementType.TOP_LEVEL + ) + assert result is _COOLDOWN