From 7698f04a7cad5688c78a5f0d8e4cdc29e9209a65 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 26 Mar 2026 21:28:46 -0400 Subject: [PATCH] feat(resolver): add PyPI cooldown policy to reject recently-published versions Adds --pypi-min-age (FROMAGER_PYPI_MIN_AGE) to reject package versions published fewer than N days ago, protecting against supply-chain attacks. Enforcement is automatic for all PyPI resolutions including custom plugins, and fail-closed when upload_time metadata is missing. Co-Authored-By: Claude Related: #877 --- docs/how-tos/index.rst | 1 + docs/how-tos/pypi-cooldown.rst | 115 +++++++ e2e/ci_bootstrap_suite.sh | 5 + e2e/test_bootstrap_cooldown.sh | 84 +++++ ..._bootstrap_cooldown_constraint_conflict.sh | 55 ++++ e2e/test_bootstrap_cooldown_transitive.sh | 81 +++++ src/fromager/__main__.py | 17 + src/fromager/context.py | 19 ++ src/fromager/resolver.py | 61 ++++ tests/test_cooldown.py | 301 ++++++++++++++++++ 10 files changed, 739 insertions(+) create mode 100644 docs/how-tos/pypi-cooldown.rst create mode 100755 e2e/test_bootstrap_cooldown.sh create mode 100755 e2e/test_bootstrap_cooldown_constraint_conflict.sh create mode 100755 e2e/test_bootstrap_cooldown_transitive.sh create mode 100644 tests/test_cooldown.py diff --git a/docs/how-tos/index.rst b/docs/how-tos/index.rst index fd0465b0..e4b6fdbd 100644 --- a/docs/how-tos/index.rst +++ b/docs/how-tos/index.rst @@ -48,6 +48,7 @@ Customize builds with overrides, variants, and version handling. pyproject-overrides multiple-versions pre-release-versions + pypi-cooldown Analyzing Builds ---------------- diff --git a/docs/how-tos/pypi-cooldown.rst b/docs/how-tos/pypi-cooldown.rst new file mode 100644 index 00000000..814e417d --- /dev/null +++ b/docs/how-tos/pypi-cooldown.rst @@ -0,0 +1,115 @@ +Protect Against Supply-Chain Attacks with PyPI Cooldown +======================================================== + +Fromager's PyPI cooldown policy rejects package versions that were published +fewer than a configured number of days ago. This protects automated builds from +supply-chain attacks where a malicious version is published and immediately +pulled in before it can be reviewed. + +How It Works +------------ + +When a cooldown is active, any candidate whose ``upload-time`` is more recent +than the cutoff (current time minus the configured minimum age) is not +considered a valid option during constraint resolution. If no versions of a +package satisfy both the cooldown window and any other provided constraints, +resolution fails with an informative error. + +The cutoff timestamp is fixed at the start of each run, so all package +resolutions within a single bootstrap share the same boundary. + +Enabling the Cooldown +--------------------- + +Use the global ``--pypi-min-age`` flag, or set the equivalent environment +variable ``FROMAGER_PYPI_MIN_AGE``: + +.. code-block:: bash + + # Reject versions published in the last 7 days + fromager --pypi-min-age 7 bootstrap -r requirements.txt + + # Same, via environment variable (useful for CI and builder integrations) + FROMAGER_PYPI_MIN_AGE=7 fromager bootstrap -r requirements.txt + + # Disable the cooldown (default) + fromager --pypi-min-age 0 bootstrap -r requirements.txt + +The ``--pypi-min-age`` flag accepts a non-negative integer number of days. +A value of ``0`` (the default) disables the check entirely. + +Scope +----- + +The cooldown applies only to **sdist resolution** — selecting which version of a +package to build from source, including transitive dependencies. It does not +apply to: + +* Wheel-only lookups, including cache servers (``--cache-wheel-server-url``) and + packages configured as ``pre_built: true`` in variant settings. These use a + different trust model and are not subject to the cooldown regardless of which + server they are fetched from. +* Packages resolved from Git URLs, which use a separate code path. + +Note that sdist resolution from a private package index depends on +``upload-time`` being present in the index's PEP 691 JSON responses. If the +index does not provide that metadata, candidates will be rejected under the +fail-closed policy described below. + +Explicit version pins (``package==1.2.3``) are subject to the same cooldown as +unpinned requirements. If the pinned version was published within the cooldown +window, resolution will fail. To unblock a specific run, set ``--pypi-min-age 0`` +or use the environment variable. + +Fail-Closed Behavior +-------------------- + +If a candidate has no ``upload-time`` metadata — which can occur with older +PyPI Simple HTML responses — it is rejected when a cooldown is active. Fromager +uses the `PEP 691 JSON Simple API`_ when fetching package metadata, which +reliably includes upload timestamps. + +.. _PEP 691 JSON Simple API: https://peps.python.org/pep-0691/ + +Example +------- + +Given a package ``example-pkg`` with three available versions: + +* ``2.0.0`` — published 3 days ago +* ``1.9.0`` — published 45 days ago +* ``1.8.0`` — published 120 days ago + +With a 7-day cooldown, ``2.0.0`` is blocked and ``1.9.0`` is selected: + +.. code-block:: bash + + fromager --pypi-min-age 7 bootstrap example-pkg + +With a 60-day cooldown, both ``2.0.0`` and ``1.9.0`` are blocked and ``1.8.0`` +is selected: + +.. code-block:: bash + + fromager --pypi-min-age 60 bootstrap example-pkg + +Overriding the Cooldown for a Pinned Version +-------------------------------------------- + +If a pinned version is blocked by the cooldown, resolution fails with a message +identifying the cause: + +.. code-block:: console + + $ fromager --pypi-min-age 7 bootstrap example-pkg==2.0.0 + ERROR: found 1 candidate(s) for example-pkg==2.0.0 but all were published + within the last 7 days (PyPI cooldown; oldest is 3 day(s) old) + +To override the cooldown for a single run, set ``--pypi-min-age 0``: + +.. code-block:: bash + + fromager --pypi-min-age 0 bootstrap example-pkg==2.0.0 + +A value of ``0`` disables the cooldown entirely, allowing any version to be +selected regardless of when it was published. diff --git a/e2e/ci_bootstrap_suite.sh b/e2e/ci_bootstrap_suite.sh index 3d4db445..0a2c7a4f 100755 --- a/e2e/ci_bootstrap_suite.sh +++ b/e2e/ci_bootstrap_suite.sh @@ -26,6 +26,11 @@ run_test "bootstrap_prerelease" run_test "bootstrap_cache" run_test "bootstrap_sdist_only" +test_section "bootstrap cooldown tests" +run_test "bootstrap_cooldown" +run_test "bootstrap_cooldown_transitive" +run_test "bootstrap_cooldown_constraint_conflict" + test_section "bootstrap git URL tests" run_test "bootstrap_git_url" run_test "bootstrap_git_url_tag" diff --git a/e2e/test_bootstrap_cooldown.sh b/e2e/test_bootstrap_cooldown.sh new file mode 100755 index 00000000..64c85535 --- /dev/null +++ b/e2e/test_bootstrap_cooldown.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Tests that --pypi-min-age rejects versions published within the cooldown +# window and falls back to an older stevedore version. Verifies both the +# CLI flag (--pypi-min-age) and the equivalent environment variable +# (FROMAGER_PYPI_MIN_AGE) produce identical behaviour. +# +# Release timeline (all times UTC): +# +# stevedore 5.1.0 2023-05-15 (the expected fallback) +# stevedore 5.2.0 2024-02-22 (blocked by cooldown) +# stevedore 5.3.0+ 2024-08-22+ (all blocked by cooldown) +# +# We compute --pypi-min-age dynamically as the age of stevedore 5.2.0 in days +# plus a 1-day buffer, ensuring stevedore 5.2.0 is always just inside the +# cooldown window while stevedore 5.1.0 (released ~9 months earlier) always +# clears it. +# +# The margin between the cutoff and stevedore 5.1.0's age is fixed at ~292 +# days (the gap between the two release dates minus the 1-day buffer), so +# this test remains stable indefinitely as long as no new stevedore release +# lands between 5.1.0 and 5.2.0. + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPTDIR/common.sh" + +# Compute min-age: days since stevedore 5.2.0 was published, plus a buffer. +# stevedore 5.2.0 was released 2024-02-22; adding 1 day ensures it is +# always just inside the cooldown window regardless of when the test runs. +MIN_AGE=$(python3 -c " +from datetime import date +age = (date.today() - date(2024, 2, 22)).days +print(age + 1) +") + +# --- Pass 1: enforce cooldown via CLI flag --- + +fromager \ + --log-file="$OUTDIR/bootstrap-flag.log" \ + --error-log-file="$OUTDIR/fromager-errors-flag.log" \ + --sdists-repo="$OUTDIR/sdists-repo" \ + --wheels-repo="$OUTDIR/wheels-repo" \ + --work-dir="$OUTDIR/work-dir" \ + --pypi-min-age="$MIN_AGE" \ + bootstrap 'stevedore' + +pass=true + +# stevedore 5.2.0 is blocked; the resolver must fall back to 5.1.0. +if ! grep -q "new toplevel dependency stevedore resolves to 5.1.0" "$OUTDIR/bootstrap-flag.log"; then + echo "FAIL (flag): expected stevedore to resolve to 5.1.0 but it did not" 1>&2 + pass=false +fi + +if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.1.0*.whl' | grep -q .; then + echo "FAIL (flag): stevedore-5.1.0 wheel not found in wheels-repo" 1>&2 + pass=false +fi + +# --- Pass 2: enforce the same cooldown via environment variable --- + +# Wipe output so the second run starts clean. +rm -rf "$OUTDIR/sdists-repo" "$OUTDIR/wheels-repo" "$OUTDIR/work-dir" + +FROMAGER_PYPI_MIN_AGE="$MIN_AGE" fromager \ + --log-file="$OUTDIR/bootstrap-envvar.log" \ + --error-log-file="$OUTDIR/fromager-errors-envvar.log" \ + --sdists-repo="$OUTDIR/sdists-repo" \ + --wheels-repo="$OUTDIR/wheels-repo" \ + --work-dir="$OUTDIR/work-dir" \ + bootstrap 'stevedore' + +if ! grep -q "new toplevel dependency stevedore resolves to 5.1.0" "$OUTDIR/bootstrap-envvar.log"; then + echo "FAIL (envvar): expected stevedore to resolve to 5.1.0 but it did not" 1>&2 + pass=false +fi + +if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.1.0*.whl' | grep -q .; then + echo "FAIL (envvar): stevedore-5.1.0 wheel not found in wheels-repo" 1>&2 + pass=false +fi + +$pass diff --git a/e2e/test_bootstrap_cooldown_constraint_conflict.sh b/e2e/test_bootstrap_cooldown_constraint_conflict.sh new file mode 100755 index 00000000..bdd80b90 --- /dev/null +++ b/e2e/test_bootstrap_cooldown_constraint_conflict.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Tests that --pypi-min-age causes resolution to fail with a clear error when +# a constraint pins a dependency to a version that falls within the cooldown +# window (i.e., the only candidate allowed by the constraint is too recent). +# +# Release timeline (all times UTC): +# +# pbr 7.0.3 2025-11-03 (pinned by constraint; blocked by cooldown) +# +# We pin pbr==7.0.3 via a constraints file and set --pypi-min-age to the age +# of pbr 7.0.3 plus a 1-day buffer, so pbr 7.0.3 is always just inside the +# cooldown window. Because the constraint eliminates all other pbr candidates, +# the resolver has no valid version to select and must fail. +# +# The expected error message confirms that fromager correctly identifies the +# cooldown as the cause of the resolution failure rather than emitting a +# generic "no match found" error. + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPTDIR/common.sh" + +# Compute min-age: days since pbr 7.0.3 was published, plus a buffer. +# pbr 7.0.3 was released 2025-11-03; adding 1 day ensures it is always +# just inside the cooldown window regardless of when the test runs. +MIN_AGE=$(python3 -c " +from datetime import date +age = (date.today() - date(2025, 11, 3)).days +print(age + 1) +") + +constraints_file=$(mktemp) +trap "rm -f $constraints_file" EXIT +echo "pbr==7.0.3" > "$constraints_file" + +fromager \ + --log-file="$OUTDIR/bootstrap.log" \ + --error-log-file="$OUTDIR/fromager-errors.log" \ + --sdists-repo="$OUTDIR/sdists-repo" \ + --wheels-repo="$OUTDIR/wheels-repo" \ + --work-dir="$OUTDIR/work-dir" \ + --constraints-file="$constraints_file" \ + --pypi-min-age="$MIN_AGE" \ + bootstrap 'stevedore' || true + +pass=true + +# The resolver must fail with a message identifying pbr and the cooldown as the cause. +if ! grep -q "candidate(s) for pbr.*but all were published within the last.*days (PyPI cooldown" "$OUTDIR/fromager-errors.log"; then + echo "FAIL: expected pbr cooldown error in fromager-errors.log but did not find it" 1>&2 + pass=false +fi + +$pass diff --git a/e2e/test_bootstrap_cooldown_transitive.sh b/e2e/test_bootstrap_cooldown_transitive.sh new file mode 100755 index 00000000..1a2a641c --- /dev/null +++ b/e2e/test_bootstrap_cooldown_transitive.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Tests that --pypi-min-age applies to transitive dependencies, forcing both +# stevedore and its dependency pbr to fall back to older versions. +# +# Release timeline (all times UTC): +# +# stevedore 5.1.0 2023-05-15 (the expected fallback for stevedore) +# pbr 6.0.0 2023-11-07 (blocked by cooldown — the anchor date) +# stevedore 5.2.0 2024-02-22 (blocked by cooldown) +# pbr 6.1.0 2024-08-27 (blocked by cooldown) +# stevedore 5.3.0+ 2024-08-22+ (all blocked by cooldown) +# pbr 7.x 2025-08-13+ (all blocked by cooldown) +# +# pbr 5.11.1 2023-01-11 (the expected fallback for pbr) +# +# We compute --pypi-min-age dynamically as the age of pbr 6.0.0 in days plus +# a 1-day buffer. This places the cutoff just past pbr 6.0.0's release, +# which also falls past stevedore 5.2.0 (released ~107 days after pbr 6.0.0). +# +# The margin between the cutoff and stevedore 5.1.0 is fixed at ~175 days +# (2023-11-07 minus 2023-05-15, less the 1-day buffer), so stevedore 5.1.0 +# always clears the cooldown window regardless of when the test runs. +# +# The margin between the cutoff and pbr 5.11.1 is fixed at ~304 days +# (2023-11-07 minus 2023-01-11, less the 1-day buffer), so pbr 5.11.1 +# similarly always clears the window. + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPTDIR/common.sh" + +# Compute min-age: days since pbr 6.0.0 was published, plus a buffer. +# pbr 6.0.0 was released 2023-11-07; adding 1 day ensures it is always +# just inside the cooldown window and forces the resolver to pbr 5.11.1. +# Because stevedore 5.2.0 (2024-02-22) was released ~107 days after pbr +# 6.0.0, it is also blocked, and the resolver falls back to stevedore 5.1.0. +MIN_AGE=$(python3 -c " +from datetime import date +age = (date.today() - date(2023, 11, 7)).days +print(age + 1) +") + +fromager \ + --log-file="$OUTDIR/bootstrap.log" \ + --error-log-file="$OUTDIR/fromager-errors.log" \ + --sdists-repo="$OUTDIR/sdists-repo" \ + --wheels-repo="$OUTDIR/wheels-repo" \ + --work-dir="$OUTDIR/work-dir" \ + --pypi-min-age="$MIN_AGE" \ + bootstrap 'stevedore' + +find "$OUTDIR/wheels-repo/" -name '*.whl' + +pass=true + +# stevedore 5.2.0+ are all blocked; the resolver must fall back to 5.1.0. +if ! grep -q "new toplevel dependency stevedore resolves to 5.1.0" "$OUTDIR/bootstrap.log"; then + echo "FAIL: expected stevedore to resolve to 5.1.0 but it did not" 1>&2 + pass=false +fi + +# pbr 6.0.0+ are all blocked; the resolver must fall back to 5.11.1. +# pbr is first resolved as a build-backend dependency so we match any dep type. +if ! grep -q "dependency pbr.*resolves to 5.11.1" "$OUTDIR/bootstrap.log"; then + echo "FAIL: expected pbr to resolve to 5.11.1 but it did not" 1>&2 + pass=false +fi + +# Confirm the expected wheels were actually produced. +if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.1.0*.whl' | grep -q .; then + echo "FAIL: stevedore-5.1.0 wheel not found in wheels-repo" 1>&2 + pass=false +fi + +if ! find "$OUTDIR/wheels-repo/downloads/" -name 'pbr-5.11.1*.whl' | grep -q .; then + echo "FAIL: pbr-5.11.1 wheel not found in wheels-repo" 1>&2 + pass=false +fi + +$pass diff --git a/src/fromager/__main__.py b/src/fromager/__main__.py index a2d4d891..c85e7a07 100644 --- a/src/fromager/__main__.py +++ b/src/fromager/__main__.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import datetime import logging import pathlib import sys @@ -143,6 +144,16 @@ help="Build sdist and when with network isolation (unshare -cn)", show_default=True, ) +@click.option( + "--pypi-min-age", + type=click.IntRange(min=0), + default=0, + envvar="FROMAGER_PYPI_MIN_AGE", + help=( + "Reject PyPI package versions published fewer than this many days ago " + "(0 disables the check). Also settable via FROMAGER_PYPI_MIN_AGE." + ), +) @click.pass_context def main( ctx: click.Context, @@ -163,6 +174,7 @@ def main( variant: str, jobs: int | None, network_isolation: bool, + pypi_min_age: int, ) -> None: # Save the debug flag so invoke_main() can use it. global _DEBUG @@ -249,6 +261,11 @@ def main( network_isolation=network_isolation, max_jobs=jobs, settings_dir=settings_dir, + pypi_cooldown=( + context.Cooldown(min_age=datetime.timedelta(days=pypi_min_age)) + if pypi_min_age > 0 + else None + ), ) wkctx.setup() ctx.obj = wkctx diff --git a/src/fromager/context.py b/src/fromager/context.py index 996971ea..b393b604 100644 --- a/src/fromager/context.py +++ b/src/fromager/context.py @@ -1,6 +1,8 @@ from __future__ import annotations import collections +import dataclasses +import datetime import logging import os import pathlib @@ -31,6 +33,20 @@ ROOT_BUILD_REQUIREMENT = canonicalize_name("", validate=False) +@dataclasses.dataclass +class Cooldown: + """Policy for rejecting recently-published package versions. + + bootstrap_time is fixed at construction so all resolutions in a single run + share the same cutoff. + """ + + min_age: datetime.timedelta + bootstrap_time: datetime.datetime = dataclasses.field( + default_factory=lambda: datetime.datetime.now(datetime.UTC) + ) + + class WorkContext: def __init__( self, @@ -46,6 +62,7 @@ def __init__( max_jobs: int | None = None, settings_dir: pathlib.Path | None = None, wheel_server_url: str = "", + pypi_cooldown: Cooldown | None = None, ): if active_settings is None: active_settings = packagesettings.Settings( @@ -95,6 +112,8 @@ def __init__( self._parallel_builds = False + self.pypi_cooldown: Cooldown | None = pypi_cooldown + def enable_parallel_builds(self) -> None: self._parallel_builds = True diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index d6790ef2..57b58d02 100644 --- a/src/fromager/resolver.py +++ b/src/fromager/resolver.py @@ -33,6 +33,7 @@ from . import overrides from .candidate import Candidate from .constraints import Constraints +from .context import Cooldown from .extras_provider import ExtrasProvider from .http_retry import RETRYABLE_EXCEPTIONS, retry_on_exception from .request_session import session @@ -48,6 +49,7 @@ PYTHON_VERSION = Version(python_version()) DEBUG_RESOLVER = os.environ.get("DEBUG_RESOLVER", "") PYPI_SERVER_URL = "https://pypi.org/simple" + GITHUB_URL = "https://github.com" # all supported tags @@ -125,6 +127,7 @@ def default_resolver_provider( constraints=ctx.constraints, req_type=req_type, ignore_platform=ignore_platform, + cooldown=ctx.pypi_cooldown, ) @@ -477,6 +480,7 @@ def validate_candidate( f"{identifier}: skipping bad version {candidate.version} from {bad_versions}" ) return False + for r in identifier_reqs: if self.is_satisfied_by(requirement=r, candidate=candidate): return True @@ -620,12 +624,17 @@ def __init__( *, use_resolver_cache: bool = True, override_download_url: str | None = None, + cooldown: Cooldown | None = None, ): super().__init__( constraints=constraints, req_type=req_type, use_resolver_cache=use_resolver_cache, ) + # In this initial implementation, cooldown only applies to sdist + # resolution. Wheel-only lookups (cache servers, pre_built packages) + # use a different trust model. + self.cooldown = cooldown if include_sdists else None self.include_sdists = include_sdists self.include_wheels = include_wheels self.sdist_server_url = sdist_server_url @@ -676,6 +685,29 @@ def validate_candidate( f"{identifier}: skipping {candidate} because it is a wheel" ) return False + # Fail closed: if upload_time is missing we cannot verify the package + # is old enough, so we reject it rather than silently bypassing the policy. + if self.cooldown is not None: + if candidate.upload_time is None: + if DEBUG_RESOLVER: + logger.debug( + "%s: skipping %s — upload_time unknown, required for cooldown", + identifier, + candidate.version, + ) + return False + cutoff = self.cooldown.bootstrap_time - self.cooldown.min_age + if candidate.upload_time > cutoff: + if DEBUG_RESOLVER: + age = self.cooldown.bootstrap_time - candidate.upload_time + logger.debug( + "%s: skipping %s uploaded %s ago (cooldown: %s)", + identifier, + candidate.version, + age, + self.cooldown.min_age, + ) + return False return True def _get_no_match_error_message( @@ -699,6 +731,35 @@ def _get_no_match_error_message( else: file_type_info = "wheels" + # If a cooldown is active, check whether it's responsible for the + # failure so we can give a more actionable error message. + if self.cooldown is not None: + cutoff = self.cooldown.bootstrap_time - self.cooldown.min_age + all_candidates = list(self._find_cached_candidates(identifier)) + missing_time = [c for c in all_candidates if c.upload_time is None] + cooldown_blocked = [ + c + for c in all_candidates + if c.upload_time is not None and c.upload_time > cutoff + ] + if missing_time and not cooldown_blocked: + return ( + f"found {len(missing_time)} candidate(s) for {r} but none have " + f"upload timestamp metadata; cannot enforce the " + f"{self.cooldown.min_age.days}-day cooldown" + ) + if cooldown_blocked: + oldest_days = min( + (self.cooldown.bootstrap_time - c.upload_time).days + for c in cooldown_blocked + if c.upload_time is not None + ) + return ( + f"found {len(cooldown_blocked)} candidate(s) for {r} but all " + f"were published within the last {self.cooldown.min_age.days} days " + f"(PyPI cooldown; oldest is {oldest_days} day(s) old)" + ) + return ( f"found no match for {r} using {self.get_provider_description()}, " f"searching for {file_type_info}, {prerelease_info} pre-release versions" diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py new file mode 100644 index 00000000..f824a85b --- /dev/null +++ b/tests/test_cooldown.py @@ -0,0 +1,301 @@ +"""Tests for the PyPI cooldown policy (issue #877). + +The cooldown rejects package versions published fewer than N days ago, +protecting against supply-chain attacks where a malicious version is +published and immediately pulled in by automated builds. +""" + +import datetime +import logging +import pathlib +import typing +from collections import defaultdict + +import pytest +import requests_mock +import resolvelib +from packaging.requirements import Requirement +from packaging.version import Version + +from fromager import context, resolver +from fromager.context import Cooldown + +_BOOTSTRAP_TIME = datetime.datetime(2026, 3, 26, 0, 0, 0, tzinfo=datetime.UTC) +_COOLDOWN_7_DAYS = datetime.timedelta(days=7) +# cutoff = 2026-03-19T00:00:00Z + +# Use PEP 691 JSON format — pypi_simple reliably parses upload-time from JSON. +_PYPI_SIMPLE_JSON_CONTENT_TYPE = "application/vnd.pypi.simple.v1+json" + +# Three versions at known ages: +# 2.0.0 uploaded 2026-03-24 → 2 days old (within cooldown) +# 1.3.2 uploaded 2026-03-15 → 11 days old (outside cooldown) +# 1.2.2 uploaded 2026-01-01 → 84 days old (outside cooldown) +_cooldown_json_response = { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": "test_pkg-2.0.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/test_pkg-2.0.0-py3-none-any.whl", + "hashes": {"sha256": "aaa"}, + "upload-time": "2026-03-24T00:00:00+00:00", + }, + { + "filename": "test_pkg-1.3.2-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/test_pkg-1.3.2-py3-none-any.whl", + "hashes": {"sha256": "bbb"}, + "upload-time": "2026-03-15T00:00:00+00:00", + }, + { + "filename": "test_pkg-1.2.2-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/test_pkg-1.2.2-py3-none-any.whl", + "hashes": {"sha256": "ccc"}, + "upload-time": "2026-01-01T00:00:00+00:00", + }, + ], +} + +_all_recent_json_response = { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": "test_pkg-2.0.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/test_pkg-2.0.0-py3-none-any.whl", + "hashes": {"sha256": "aaa"}, + "upload-time": "2026-03-25T00:00:00+00:00", + }, + { + "filename": "test_pkg-1.3.2-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/test_pkg-1.3.2-py3-none-any.whl", + "hashes": {"sha256": "bbb"}, + "upload-time": "2026-03-24T00:00:00+00:00", + }, + ], +} + +_COOLDOWN = Cooldown( + min_age=_COOLDOWN_7_DAYS, + bootstrap_time=_BOOTSTRAP_TIME, +) + + +@pytest.fixture(autouse=True) +def clear_resolver_cache() -> typing.Generator[None, None, None]: + """Clear the class-level resolver cache before each test. + + BaseProvider.resolver_cache is a ClassVar that persists across test + instances. Without clearing it, candidates fetched in one test are reused + by subsequent tests, bypassing mocked HTTP responses and producing + incorrect results. + """ + resolver.BaseProvider.clear_cache() + yield + + +def test_cooldown_filters_recent_version( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Versions within the cooldown window are skipped; older ones are selected.""" + monkeypatch.setattr(resolver, "DEBUG_RESOLVER", "1") + 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}, + ) + provider = resolver.PyPIProvider(include_sdists=True, cooldown=_COOLDOWN) + rslvr = resolvelib.Resolver(provider, resolvelib.BaseReporter()) + with caplog.at_level(logging.DEBUG, logger="fromager.resolver"): + result = rslvr.resolve([Requirement("test-pkg")]) + + candidate = result.mapping["test-pkg"] + # 2.0.0 is 2 days old (within cooldown); 1.3.2 is 11 days old (outside). + assert str(candidate.version) == "1.3.2" + # 2.0.0 should be logged as skipped; 1.3.2 should not. + assert "skipping 2.0.0" in caplog.text + assert "cooldown" in caplog.text + assert "skipping 1.3.2" not in caplog.text + + +def test_cooldown_disabled_selects_latest() -> None: + """Without a cooldown the resolver selects the latest version as normal.""" + 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}, + ) + provider = resolver.PyPIProvider(include_sdists=True, cooldown=None) + rslvr = resolvelib.Resolver(provider, resolvelib.BaseReporter()) + result = rslvr.resolve([Requirement("test-pkg")]) + + candidate = result.mapping["test-pkg"] + assert str(candidate.version) == "2.0.0" + + +def test_cooldown_all_blocked_raises_informative_error() -> None: + """When all candidates are within the cooldown window the error says so.""" + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=_all_recent_json_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + provider = resolver.PyPIProvider(include_sdists=True, cooldown=_COOLDOWN) + rslvr = resolvelib.Resolver(provider, resolvelib.BaseReporter()) + + with pytest.raises(resolvelib.resolvers.ResolverException) as exc_info: + rslvr.resolve([Requirement("test-pkg")]) + + msg = str(exc_info.value) + assert "2 candidate(s)" in msg + assert "published within the last 7 days (PyPI cooldown" in msg + + +def test_cooldown_rejects_candidate_without_upload_time( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A candidate with no upload_time is rejected when a cooldown is active (fail closed).""" + monkeypatch.setattr(resolver, "DEBUG_RESOLVER", "1") + candidate = resolver.Candidate( + name="test-pkg", + version=Version("1.0.0"), + url="https://example.com/test-pkg-1.0.0.tar.gz", + upload_time=None, + ) + provider = resolver.PyPIProvider(cooldown=_COOLDOWN) + req = Requirement("test-pkg") + requirements: typing.Any = defaultdict(list) + requirements["test-pkg"].append(req) + incompatibilities: typing.Any = defaultdict(list) + + with caplog.at_level(logging.DEBUG, logger="fromager.resolver"): + result = provider.validate_candidate( + "test-pkg", requirements, incompatibilities, candidate + ) + + assert result is False + assert "upload_time unknown" in caplog.text + assert "1.0.0" in caplog.text + + +def test_cooldown_missing_timestamp_error_message() -> None: + """Resolution failure due to missing timestamps produces a clear error message.""" + no_timestamp_response = { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": "test_pkg-1.0.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/test_pkg-1.0.0-py3-none-any.whl", + "hashes": {"sha256": "aaa"}, + }, + ], + } + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=no_timestamp_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + provider = resolver.PyPIProvider(include_sdists=True, cooldown=_COOLDOWN) + + with pytest.raises(resolvelib.resolvers.ResolverException) as exc_info: + resolvelib.Resolver(provider, resolvelib.BaseReporter()).resolve( + [Requirement("test-pkg")] + ) + + assert "upload timestamp" in str(exc_info.value) + + +def test_cooldown_applied_automatically_via_ctx(tmp_path: pathlib.Path) -> None: + """ctx.pypi_cooldown propagates through both the direct and full resolve paths. + + Verifies two levels of the call stack without requiring a real build: + - default_resolver_provider(ctx=ctx) picks up the cooldown directly + - resolver.resolve(ctx=ctx) picks it up through find_and_invoke + Plugin authors who call either function get cooldown enforcement for free. + """ + 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", + pypi_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}, + ) + + # Via default_resolver_provider directly. + provider = resolver.default_resolver_provider( + ctx=ctx, + req=Requirement("test-pkg"), + sdist_server_url="https://pypi.org/simple/", + include_sdists=True, + include_wheels=True, + ) + result = resolvelib.Resolver(provider, resolvelib.BaseReporter()).resolve( + [Requirement("test-pkg")] + ) + assert str(result.mapping["test-pkg"].version) == "1.3.2" + + # Via resolver.resolve() (exercises find_and_invoke path). + resolver.BaseProvider.clear_cache() + _, version = resolver.resolve( + ctx=ctx, + req=Requirement("test-pkg"), + sdist_server_url="https://pypi.org/simple/", + include_sdists=True, + include_wheels=True, + ) + assert str(version) == "1.3.2" + + +def test_wheel_only_resolution_ignores_cooldown_without_upload_time() -> None: + """include_sdists=False suppresses the cooldown even when a cooldown is configured. + + Cache servers and prebuilt wheel servers (fromager wheel-server, Pulp, + GitLab package registry) serve Simple HTML v1.0 with no upload_time. + Cooldown only applies to sdist resolution from a public index; wheel-only + lookups use a different trust model and must never fail-closed against + servers that structurally cannot provide timestamps. + """ + no_timestamp_response = { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": "test_pkg-1.3.2-py3-none-any.whl", + "url": "https://cache.example.com/packages/test_pkg-1.3.2-py3-none-any.whl", + "hashes": {"sha256": "bbb"}, + # no upload-time — as served by Simple HTML v1.0 + }, + ], + } + with requests_mock.Mocker() as r: + r.get( + "https://cache.example.com/simple/test-pkg/", + json=no_timestamp_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + provider = resolver.PyPIProvider( + sdist_server_url="https://cache.example.com/simple/", + include_sdists=False, + include_wheels=True, + cooldown=_COOLDOWN, # cooldown configured but must not fire for wheel-only + ) + result = resolvelib.Resolver(provider, resolvelib.BaseReporter()).resolve( + [Requirement("test-pkg==1.3.2")] + ) + assert str(result.mapping["test-pkg"].version) == "1.3.2"