diff --git a/docs/how-tos/index.rst b/docs/how-tos/index.rst index fd0465b0..a73e8439 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 + release-age-cooldown Analyzing Builds ---------------- diff --git a/docs/how-tos/release-age-cooldown.rst b/docs/how-tos/release-age-cooldown.rst new file mode 100644 index 00000000..e494cd91 --- /dev/null +++ b/docs/how-tos/release-age-cooldown.rst @@ -0,0 +1,113 @@ +Protect Against Supply-Chain Attacks with Release-Age Cooldown +============================================================== + +Fromager's release-age 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 ``--min-release-age`` flag, or set the equivalent environment +variable ``FROMAGER_MIN_RELEASE_AGE``: + +.. code-block:: bash + + # Reject versions published in the last 7 days + fromager --min-release-age 7 bootstrap -r requirements.txt + + # Same, via environment variable (useful for CI and builder integrations) + FROMAGER_MIN_RELEASE_AGE=7 fromager bootstrap -r requirements.txt + + # Disable the cooldown (default) + fromager --min-release-age 0 bootstrap -r requirements.txt + +The ``--min-release-age`` flag accepts a non-negative integer number of days. +A value of ``0`` (the default) disables the check entirely. + +Scope +----- + +The cooldown applies 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 that do not provide timestamp metadata. + +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. + + +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 --min-release-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 --min-release-age 60 bootstrap example-pkg + +Per-Package Override +-------------------- + +The cooldown can be adjusted on a per-package basis using the +``resolver_dist.min_release_age`` setting in the package's settings file: + +.. code-block:: yaml + + # overrides/settings/my-package.yaml + resolver_dist: + min_release_age: 0 # disable cooldown for this package + # min_release_age: 30 # or use a different number of days + +Valid values: + +* Omit the key (default): inherit the global ``--min-release-age`` setting. +* ``0``: disable the cooldown for this package, regardless of the global flag. +* Positive integer: use this many days instead of the global setting. + +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. diff --git a/e2e/ci_bootstrap_suite.sh b/e2e/ci_bootstrap_suite.sh index 3d4db445..1a952318 100755 --- a/e2e/ci_bootstrap_suite.sh +++ b/e2e/ci_bootstrap_suite.sh @@ -26,6 +26,13 @@ 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_gitlab" +run_test "bootstrap_cooldown_github" +run_test "bootstrap_cooldown_override" + test_section "bootstrap git URL tests" run_test "bootstrap_git_url" run_test "bootstrap_git_url_tag" diff --git a/e2e/cooldown_override_settings/stevedore.yaml b/e2e/cooldown_override_settings/stevedore.yaml new file mode 100644 index 00000000..fdf3fe11 --- /dev/null +++ b/e2e/cooldown_override_settings/stevedore.yaml @@ -0,0 +1,2 @@ +resolver_dist: + min_release_age: 0 diff --git a/e2e/github_override_example/pyproject.toml b/e2e/github_override_example/pyproject.toml new file mode 100644 index 00000000..05664b09 --- /dev/null +++ b/e2e/github_override_example/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "github-example-with-package-plugin" +version = "0.1.0" +description = "Example Fromager package plugin demonstrating GitHubTagProvider via get_resolver_provider" +requires-python = ">=3.12" +dependencies = [] + +[project.entry-points."fromager.project_overrides"] +stevedore = "package_plugins.stevedore_github" diff --git a/e2e/github_override_example/src/package_plugins/__init__.py b/e2e/github_override_example/src/package_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/e2e/github_override_example/src/package_plugins/stevedore_github.py b/e2e/github_override_example/src/package_plugins/stevedore_github.py new file mode 100644 index 00000000..c5eb6f5b --- /dev/null +++ b/e2e/github_override_example/src/package_plugins/stevedore_github.py @@ -0,0 +1,26 @@ +from packaging.requirements import Requirement + +from fromager import context, resolver + + +def get_resolver_provider( + ctx: context.WorkContext, + req: Requirement, + sdist_server_url: str, + include_sdists: bool, + include_wheels: bool, + req_type: resolver.RequirementType | None = None, + ignore_platform: bool = False, +) -> resolver.GitHubTagProvider: + """Return a GitHubTagProvider for the stevedore test repo on github.com.""" + return resolver.GitHubTagProvider( + organization="python-wheel-build", + repo="stevedore-test-repo", + constraints=ctx.constraints, + req_type=req_type, + cooldown=ctx.cooldown, + override_download_url=( + "https://github.com/{organization}/{repo}" + "/archive/refs/tags/{tagname}.tar.gz" + ), + ) diff --git a/e2e/gitlab_override_example/pyproject.toml b/e2e/gitlab_override_example/pyproject.toml new file mode 100644 index 00000000..513b4427 --- /dev/null +++ b/e2e/gitlab_override_example/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "gitlab-example-with-package-plugin" +version = "0.1.0" +description = "Example Fromager package plugin demonstrating GitLabTagProvider via get_resolver_provider" +requires-python = ">=3.12" +dependencies = [] + +[project.entry-points."fromager.project_overrides"] +python_gitlab = "package_plugins.python_gitlab" diff --git a/e2e/gitlab_override_example/src/package_plugins/__init__.py b/e2e/gitlab_override_example/src/package_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/e2e/gitlab_override_example/src/package_plugins/python_gitlab.py b/e2e/gitlab_override_example/src/package_plugins/python_gitlab.py new file mode 100644 index 00000000..2ee7a6c0 --- /dev/null +++ b/e2e/gitlab_override_example/src/package_plugins/python_gitlab.py @@ -0,0 +1,21 @@ +from packaging.requirements import Requirement + +from fromager import context, resolver + + +def get_resolver_provider( + ctx: context.WorkContext, + req: Requirement, + sdist_server_url: str, + include_sdists: bool, + include_wheels: bool, + req_type: resolver.RequirementType | None = None, + ignore_platform: bool = False, +) -> resolver.GitLabTagProvider: + """Return a GitLabTagProvider for the python-gitlab project on gitlab.com.""" + return resolver.GitLabTagProvider( + project_path="python-gitlab/python-gitlab", + constraints=ctx.constraints, + req_type=req_type, + cooldown=ctx.cooldown, + ) diff --git a/e2e/test_bootstrap_cooldown.sh b/e2e/test_bootstrap_cooldown.sh new file mode 100755 index 00000000..597f1b7c --- /dev/null +++ b/e2e/test_bootstrap_cooldown.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Tests that --min-release-age rejects versions published within the cooldown +# window and falls back to an older stevedore version. Verifies both the +# CLI flag (--min-release-age) and the equivalent environment variable +# (FROMAGER_MIN_RELEASE_AGE) produce identical behaviour. +# +# Release timeline (all times UTC): +# +# stevedore 5.1.0 2023-05-15 +# stevedore 5.2.0 2024-02-22 +# stevedore 5.3.0 2024-08-22 (the expected fallback) +# stevedore 5.4.0 2024-11-20 (blocked by cooldown) +# stevedore 5.5.0+ future (all blocked by cooldown) +# +# We compute --min-release-age dynamically as the age of stevedore 5.4.0 in +# days plus a 1-day buffer, ensuring stevedore 5.4.0 is always just inside the +# cooldown window while stevedore 5.3.0 (released ~90 days earlier) always +# clears it. +# +# Anchoring to 5.4.0 (released 2024-11-20) also ensures the build toolchain +# can use flit_core 3.10.0 (released 2024-10-31), which is required for +# Python 3.14 compatibility. + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPTDIR/common.sh" + +# Compute min-age: days since stevedore 5.4.0 was published, plus a buffer. +# stevedore 5.4.0 was released 2024-11-20; 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, 11, 20)).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" \ + --min-release-age="$MIN_AGE" \ + bootstrap 'stevedore' + +pass=true + +# stevedore 5.4.0 is blocked; the resolver must fall back to 5.3.0. +if ! grep -q "new toplevel dependency stevedore resolves to 5.3.0" "$OUTDIR/bootstrap-flag.log"; then + echo "FAIL (flag): expected stevedore to resolve to 5.3.0 but it did not" 1>&2 + pass=false +fi + +if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.3.0*.whl' | grep -q .; then + echo "FAIL (flag): stevedore-5.3.0 wheel not found in wheels-repo" 1>&2 + pass=false +fi + +# --- Pass 2: enforce the same cooldown via environment variable (FROMAGER_MIN_RELEASE_AGE) --- + +# Wipe output so the second run starts clean. +rm -rf "$OUTDIR/sdists-repo" "$OUTDIR/wheels-repo" "$OUTDIR/work-dir" + +FROMAGER_MIN_RELEASE_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.3.0" "$OUTDIR/bootstrap-envvar.log"; then + echo "FAIL (envvar): expected stevedore to resolve to 5.3.0 but it did not" 1>&2 + pass=false +fi + +if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.3.0*.whl' | grep -q .; then + echo "FAIL (envvar): stevedore-5.3.0 wheel not found in wheels-repo" 1>&2 + pass=false +fi + +$pass diff --git a/e2e/test_bootstrap_cooldown_github.sh b/e2e/test_bootstrap_cooldown_github.sh new file mode 100755 index 00000000..3585d7c8 --- /dev/null +++ b/e2e/test_bootstrap_cooldown_github.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Tests that when --min-release-age is active and a package is resolved through +# the GitHubTagProvider, the cooldown is NOT enforced (because GitHub does not +# yet provide upload timestamps), but a warning is emitted for each candidate. +# +# The stevedore test repo (python-wheel-build/stevedore-test-repo) is used as +# a convenient GitHub-hosted package with known tags. +# +# MIN_AGE is anchored to stevedore 5.4.1 (2025-02-20), so it is large enough +# that enforcement WOULD block the resolved candidate — confirming that the +# GitHubTagProvider correctly skips enforcement rather than failing closed. +# Using a modest cooldown (rather than 9999 days) avoids inadvertently blocking +# PyPI build dependencies like setuptools, which are always recent. + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPTDIR/common.sh" + +# Anchor: stevedore 5.4.1 was released 2025-02-20. +# MIN_AGE exceeds its age, so the cooldown would block it if enforced. +MIN_AGE=$(python3 -c " +from datetime import date +age = (date.today() - date(2025, 2, 20)).days +print(age + 1) +") + +# Install the override plugin that routes stevedore through GitHubTagProvider. +# Uninstall on exit so its entry points don't leak into subsequent e2e tests. +trap 'python3 -m pip uninstall -y github_override_example >/dev/null 2>&1 || true' EXIT +pip install "$SCRIPTDIR/github_override_example" + +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" \ + --min-release-age="$MIN_AGE" \ + bootstrap 'stevedore' + +find "$OUTDIR/wheels-repo/" -name '*.whl' + +pass=true + +# Resolution must succeed despite the 9999-day cooldown — GitHub timestamps +# are not yet supported, so the cooldown is skipped rather than enforced. +if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-*.whl' | grep -q .; then + echo "FAIL: no stevedore wheel found — resolution should have succeeded despite cooldown" 1>&2 + pass=false +fi + +# A warning must be emitted explaining why the cooldown was skipped. +if ! grep -q "not yet implemented" "$OUTDIR/bootstrap.log"; then + echo "FAIL: expected cooldown-skipped warning not found in log" 1>&2 + pass=false +fi + +$pass diff --git a/e2e/test_bootstrap_cooldown_gitlab.sh b/e2e/test_bootstrap_cooldown_gitlab.sh new file mode 100755 index 00000000..7a7a8ccf --- /dev/null +++ b/e2e/test_bootstrap_cooldown_gitlab.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Tests that --min-release-age applies to packages resolved through the +# GitLabTagProvider, verifying that the cooldown uses GitLab tag commit +# timestamps. +# +# The python-gitlab package is a good candidate because it is hosted on +# gitlab.com, is pure Python, and has a well-documented release history. +# +# Release timeline (all times UTC): +# +# python-gitlab v5.0.0 2024-10-28 (the expected fallback) +# python-gitlab v5.1.0 2024-11-28 (blocked by cooldown — the anchor date) +# python-gitlab v5.2.0+ 2024-12-17+ (all blocked by cooldown) +# +# We compute --min-release-age dynamically as the age of v5.1.0 in days plus a +# 1-day buffer. This places the cutoff just before 2024-11-28, blocking v5.1.0 +# and all later releases while allowing v5.0.0 (released ~31 days earlier). +# +# Anchoring to v5.1.0 (2024-11-28) ensures the build toolchain can use +# flit_core 3.10.0 (released 2024-10-31), which is required for Python 3.14 +# compatibility. +# +# The gitlab_override_example plugin (installed below) registers a +# get_resolver_provider hook that returns a GitLabTagProvider for python-gitlab, +# routing resolution through the GitLab tag API instead of PyPI. + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPTDIR/common.sh" + +# Install the override plugin that routes python-gitlab through GitLabTagProvider. +# Uninstall on exit so its entry points don't leak into subsequent e2e tests. +trap 'python3 -m pip uninstall -y gitlab_override_example >/dev/null 2>&1 || true' EXIT +pip install "$SCRIPTDIR/gitlab_override_example" + +# Compute min-age: days since python-gitlab v5.1.0 was tagged, plus a buffer. +# v5.1.0 was tagged 2024-11-28; 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, 11, 28)).days +print(age + 1) +") + +fromager \ + --debug \ + --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" \ + --min-release-age="$MIN_AGE" \ + bootstrap 'python-gitlab' + +find "$OUTDIR/wheels-repo/" -name '*.whl' + +pass=true + +# v5.1.0 is blocked by the cooldown; the resolver must fall back to v5.0.0. +if ! find "$OUTDIR/wheels-repo/downloads/" -name 'python_gitlab-5.0.0*.whl' | grep -q .; then + echo "FAIL: python_gitlab-5.0.0 wheel not found — cooldown did not force fallback" 1>&2 + pass=false +fi + +# Confirm newer versions were rejected by the cooldown. +if find "$OUTDIR/wheels-repo/downloads/" -name 'python_gitlab-5.[1-9]*.whl' | grep -q .; then + echo "FAIL: a python_gitlab wheel newer than 5.0.0 was selected despite the cooldown" 1>&2 + pass=false +fi + +# Confirm the GitLabTagProvider was actually used (not the default PyPI provider). +if ! grep -q "GitLabTagProvider" "$OUTDIR/bootstrap.log"; then + echo "FAIL: GitLabTagProvider was not used for resolution" 1>&2 + pass=false +fi + +$pass diff --git a/e2e/test_bootstrap_cooldown_override.sh b/e2e/test_bootstrap_cooldown_override.sh new file mode 100755 index 00000000..00c24c4d --- /dev/null +++ b/e2e/test_bootstrap_cooldown_override.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Tests that resolver_dist.min_release_age = 0 in package settings bypasses +# the global --min-release-age flag for a specific package. +# +# Release timeline: +# +# stevedore 5.3.0 2024-08-22 (passes the global cooldown — control) +# stevedore 5.4.0 2024-11-20 (blocked by global cooldown — our target) +# +# The global cooldown is anchored to stevedore 5.4.0's release date so that +# 5.4.0 is just inside the cooldown window. Without the per-package override, +# bootstrapping 'stevedore==5.4.0' would fail. With min_release_age: 0 in the +# stevedore package settings, the cooldown is disabled for stevedore and the +# bootstrap must succeed. +# +# stevedore 5.4.0 is pinned explicitly so that only its build dependency +# (pbr>=2.0.0, satisfied by pbr 6.1.0 released 2024-08-27) is needed, and +# that version pre-dates the global cooldown cutoff. + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPTDIR/common.sh" + +# Use the same anchor as test_bootstrap_cooldown.sh: cooldown would block +# stevedore 5.4.0 and force a fallback to 5.3.0. +MIN_AGE=$(python3 -c " +from datetime import date +age = (date.today() - date(2024, 11, 20)).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" \ + --settings-dir="$SCRIPTDIR/cooldown_override_settings" \ + --min-release-age="$MIN_AGE" \ + bootstrap 'stevedore==5.4.0' + +pass=true + +# The per-package override (min_release_age: 0) must have allowed 5.4.0 through. +if ! grep -q "new toplevel dependency stevedore.*resolves to 5.4.0" "$OUTDIR/bootstrap.log"; then + echo "FAIL: stevedore did not resolve to 5.4.0 — per-package cooldown override was not applied" 1>&2 + pass=false +fi + +if ! find "$OUTDIR/wheels-repo/downloads/" -name 'stevedore-5.4.0*.whl' | grep -q .; then + echo "FAIL: stevedore-5.4.0 wheel not found — expected cooldown override to allow 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..2ae07519 --- /dev/null +++ b/e2e/test_bootstrap_cooldown_transitive.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Tests that --min-release-age applies to transitive dependencies, forcing +# pbr (a transitive dependency of stevedore) to fall back to an older version. +# +# Release timeline (all times UTC): +# +# pbr 6.1.1 2025-02-04 (the expected fallback for pbr) +# stevedore 5.4.1 2025-02-20 (resolves normally — not blocked) +# stevedore 5.5.0 2025-08-25 (blocked by cooldown) +# pbr 7.0.0 2025-08-13 (blocked by cooldown — the anchor date) +# pbr 7.0.1+ 2025-08-21+ (all blocked by cooldown) +# +# We compute --min-release-age dynamically as the age of pbr 7.0.0 in days +# plus a 1-day buffer. This places the cutoff just before 2025-08-13, which +# blocks all pbr 7.x releases while allowing pbr 6.1.1 (2025-02-04) and +# stevedore 5.4.1 (2025-02-20) to pass. +# +# The cutoff (2025-08-12) also falls after flit_core 3.10.0 (2024-10-31), +# ensuring the build toolchain is Python 3.14 compatible. + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPTDIR/common.sh" + +# Compute min-age: days since pbr 7.0.0 was published, plus a buffer. +# pbr 7.0.0 was released 2025-08-13; adding 1 day ensures it is always +# just inside the cooldown window and forces the resolver to pbr 6.1.1. +MIN_AGE=$(python3 -c " +from datetime import date +age = (date.today() - date(2025, 8, 13)).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" \ + --min-release-age="$MIN_AGE" \ + bootstrap 'stevedore' + +find "$OUTDIR/wheels-repo/" -name '*.whl' + +pass=true + +# stevedore resolves normally (5.4.1 is before the cutoff). +if ! grep -q "new toplevel dependency stevedore resolves to 5.4.1" "$OUTDIR/bootstrap.log"; then + echo "FAIL: expected stevedore to resolve to 5.4.1 but it did not" 1>&2 + pass=false +fi + +# pbr 7.0.0+ are all blocked; the resolver must fall back to 6.1.1. +# pbr is first resolved as a build-backend dependency so we match any dep type. +if ! grep -q "dependency pbr.*resolves to 6.1.1" "$OUTDIR/bootstrap.log"; then + echo "FAIL: expected pbr to resolve to 6.1.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.4.1*.whl' | grep -q .; then + echo "FAIL: stevedore-5.4.1 wheel not found in wheels-repo" 1>&2 + pass=false +fi + +if ! find "$OUTDIR/wheels-repo/downloads/" -name 'pbr-6.1.1*.whl' | grep -q .; then + echo "FAIL: pbr-6.1.1 wheel not found in wheels-repo" 1>&2 + pass=false +fi + +$pass diff --git a/pyproject.toml b/pyproject.toml index 4bf62ff4..1be5e02d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -200,6 +200,8 @@ follow_imports = "silent" exclude = [ "^e2e/.*/build/.*$", "^e2e/pyo3_test/.*$", + "^e2e/gitlab_override_example/.*$", + "^e2e/github_override_example/.*$", ] [[tool.mypy.overrides]] diff --git a/src/fromager/__main__.py b/src/fromager/__main__.py index a2d4d891..ce581709 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( + "--min-release-age", + type=click.IntRange(min=0), + default=0, + envvar="FROMAGER_MIN_RELEASE_AGE", + help=( + "Reject package versions published fewer than this many days ago " + "(0 disables the check). Also settable via FROMAGER_MIN_RELEASE_AGE." + ), +) @click.pass_context def main( ctx: click.Context, @@ -163,6 +174,7 @@ def main( variant: str, jobs: int | None, network_isolation: bool, + min_release_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, + cooldown=( + context.Cooldown(min_age=datetime.timedelta(days=min_release_age)) + if min_release_age > 0 + else None + ), ) wkctx.setup() ctx.obj = wkctx diff --git a/src/fromager/context.py b/src/fromager/context.py index 996971ea..18cb8b63 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 = "", + cooldown: Cooldown | None = None, ): if active_settings is None: active_settings = packagesettings.Settings( @@ -95,6 +112,8 @@ def __init__( self._parallel_builds = False + self.cooldown: Cooldown | None = cooldown + def enable_parallel_builds(self) -> None: self._parallel_builds = True diff --git a/src/fromager/packagesettings/_models.py b/src/fromager/packagesettings/_models.py index 47d96f6d..0c616655 100644 --- a/src/fromager/packagesettings/_models.py +++ b/src/fromager/packagesettings/_models.py @@ -96,6 +96,24 @@ class ResolverDist(pydantic.BaseModel): .. versionadded:: 0.70 """ + min_release_age: int | None = None + """Per-package minimum release age override in days. + + None (default): inherit the global ``--min-release-age`` setting. + 0: disable the release-age cooldown for this package. + Positive integer: override the cooldown with this many days. + + .. versionadded:: 0.XX + """ + + @pydantic.field_validator("min_release_age") + @classmethod + def validate_min_release_age(cls, v: int | None) -> int | None: + """Reject negative values.""" + if v is not None and v < 0: + raise ValueError("min_release_age must be a non-negative integer") + return v + @pydantic.model_validator(mode="after") def validate_ignore_platform(self) -> typing.Self: if self.ignore_platform and not self.include_wheels: diff --git a/src/fromager/packagesettings/_pbi.py b/src/fromager/packagesettings/_pbi.py index 5c8458ea..15c73823 100644 --- a/src/fromager/packagesettings/_pbi.py +++ b/src/fromager/packagesettings/_pbi.py @@ -250,6 +250,16 @@ def resolver_ignore_platform(self) -> bool: """Ignore the platform when resolving with wheels?""" return self._ps.resolver_dist.ignore_platform + @property + def resolver_min_release_age(self) -> int | None: + """Per-package release-age cooldown override in days. + + Returns None (inherit global), 0 (disabled), or a positive integer + (override days). The caller is responsible for converting to a + :class:`~fromager.context.Cooldown` instance. + """ + return self._ps.resolver_dist.min_release_age + @property def use_pypi_org_metadata(self) -> bool: """Can use metadata from pypi.org JSON / Simple API? diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index 2ee09115..d5438671 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 @@ -123,6 +124,8 @@ def default_resolver_provider( | VersionMapProvider ): """Lookup resolver provider to resolve package versions""" + pbi = ctx.package_build_info(req) + cooldown = _resolve_package_cooldown(ctx.cooldown, pbi.resolver_min_release_age) return PyPIProvider( include_sdists=include_sdists, include_wheels=include_wheels, @@ -130,6 +133,38 @@ def default_resolver_provider( constraints=ctx.constraints, req_type=req_type, ignore_platform=ignore_platform, + cooldown=cooldown, + ) + + +def _resolve_package_cooldown( + global_cooldown: Cooldown | None, + per_package_days: int | None, +) -> Cooldown | None: + """Compute the effective cooldown for a single package. + + Args: + global_cooldown: The run-wide cooldown from ``--min-release-age``. + per_package_days: The ``resolver_dist.min_release_age`` setting for + this package (None = inherit, 0 = disabled, positive = override). + + Returns: + The cooldown to pass to the provider, or ``None`` if disabled. + """ + if per_package_days is None: + return global_cooldown + if per_package_days == 0: + return None + # Per-package positive override: inherit bootstrap_time from global so all + # resolutions in a single run share the same fixed cutoff point. + bootstrap_time = ( + global_cooldown.bootstrap_time + if global_cooldown is not None + else datetime.datetime.now(datetime.UTC) + ) + return Cooldown( + min_age=datetime.timedelta(days=per_package_days), + bootstrap_time=bootstrap_time, ) @@ -424,6 +459,14 @@ def get_project_from_pypi( class BaseProvider(ExtrasProvider): resolver_cache: typing.ClassVar[ResolverCache] = {} provider_description: typing.ClassVar[str] + supports_upload_time: typing.ClassVar[bool] = True + """Does this provider supply upload timestamps for candidates? + + Set to False on providers (e.g. GitHubTagProvider) that do not yet + support timestamp retrieval. When a cooldown is active and this flag is + False, the cooldown check is skipped with a warning rather than + failing closed. + """ def __init__( self, @@ -431,11 +474,13 @@ def __init__( constraints: Constraints | None = None, req_type: RequirementType | None = None, use_resolver_cache: bool = True, + cooldown: Cooldown | None = None, ): super().__init__() self.constraints = constraints or Constraints() self.req_type = req_type self.use_cache_candidates = use_resolver_cache + self.cooldown = cooldown @property def cache_key(self) -> str: @@ -546,6 +591,42 @@ def is_satisfied_by(self, requirement: Requirement, candidate: Candidate) -> boo ) return False + # Release-age cooldown: reject candidates published too recently. + if self.cooldown is not None: + if candidate.upload_time is None: + if not self.supports_upload_time: + # Provider does not yet support timestamp retrieval (e.g. GitHub). + # Warn and skip the check rather than failing every candidate. + logger.warning( + "%s: release-age cooldown cannot be enforced — upload " + "timestamp support is not yet implemented for %s; " + "cooldown check skipped", + requirement.name, + self.get_provider_description(), + ) + return True + # Provider should supply timestamps but this candidate is missing one. + # Fail closed: we cannot verify the age, so reject it. + if DEBUG_RESOLVER: + logger.debug( + "%s: skipping %s — upload_time unknown, required for cooldown", + requirement.name, + 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)", + requirement.name, + candidate.version, + age, + self.cooldown.min_age, + ) + return False + return True def get_dependencies(self, candidate: Candidate) -> list[Requirement]: @@ -650,11 +731,15 @@ 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, + # Cooldown applies to sdist resolution only. Wheel-only lookups + # (cache servers, pre_built packages) use a different trust model. + cooldown=cooldown if include_sdists else None, ) self.include_sdists = include_sdists self.include_wheels = include_wheels @@ -729,6 +814,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"(release-age 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" @@ -760,11 +874,13 @@ def __init__( *, # generic provider does not implement caching use_resolver_cache: bool = False, + cooldown: Cooldown | None = None, ): super().__init__( constraints=constraints, req_type=req_type, use_resolver_cache=use_resolver_cache, + cooldown=cooldown, ) self._version_source = version_source if matcher is None: @@ -848,6 +964,7 @@ class GitHubTagProvider(GenericProvider): provider_description: typing.ClassVar[str] = ( "GitHub tag resolver (repository: {self.organization}/{self.repo})" ) + supports_upload_time: typing.ClassVar[bool] = False host = "github.com:443" api_url = "https://api.{self.host}/repos/{self.organization}/{self.repo}/tags" @@ -861,6 +978,7 @@ def __init__( req_type: RequirementType | None = None, use_resolver_cache: bool = True, override_download_url: str | None = None, + cooldown: Cooldown | None = None, ): super().__init__( constraints=constraints, @@ -868,6 +986,7 @@ def __init__( use_resolver_cache=use_resolver_cache, version_source=self._find_tags, matcher=matcher, + cooldown=cooldown, ) self.organization = organization self.repo = repo @@ -960,6 +1079,7 @@ def __init__( req_type: RequirementType | None = None, use_resolver_cache: bool = True, override_download_url: str | None = None, + cooldown: Cooldown | None = None, ) -> None: super().__init__( constraints=constraints, @@ -967,6 +1087,7 @@ def __init__( use_resolver_cache=use_resolver_cache, version_source=self._find_tags, matcher=matcher, + cooldown=cooldown, ) self.server_url = server_url.rstrip("/") self.server_hostname = urlparse(server_url).hostname diff --git a/tests/test_bootstrap_requirement_resolver.py b/tests/test_bootstrap_requirement_resolver.py index d8604f66..e58c5a64 100644 --- a/tests/test_bootstrap_requirement_resolver.py +++ b/tests/test_bootstrap_requirement_resolver.py @@ -488,6 +488,7 @@ def test_resolve_auto_routes_to_prebuilt( mock_pbi = MagicMock() mock_pbi.pre_built = True mock_pbi.wheel_server_url = None + mock_pbi.resolver_min_release_age = None with patch.object(tmp_context, "package_build_info", return_value=mock_pbi): resolver = BootstrapRequirementResolver(tmp_context) @@ -531,6 +532,7 @@ def test_resolve_auto_routes_to_source( mock_pbi.resolver_sdist_server_url = MagicMock( return_value="https://pypi.org/simple" ) + mock_pbi.resolver_min_release_age = None with patch.object(tmp_context, "package_build_info", return_value=mock_pbi): resolver = BootstrapRequirementResolver(tmp_context) diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py new file mode 100644 index 00000000..17718bf2 --- /dev/null +++ b/tests/test_cooldown.py @@ -0,0 +1,568 @@ +"""Tests for the release-age 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 re +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 (release-age 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.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", + 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" + + +def test_resolve_package_cooldown_inherits_global() -> None: + """None per-package override returns the global cooldown unchanged.""" + result = resolver._resolve_package_cooldown(_COOLDOWN, None) + assert result is _COOLDOWN + + +def test_resolve_package_cooldown_disabled_per_package() -> None: + """per_package_days=0 disables the cooldown for the package even when global is set.""" + result = resolver._resolve_package_cooldown(_COOLDOWN, 0) + assert result is None + + +def test_resolve_package_cooldown_disabled_no_global() -> None: + """per_package_days=0 with no global cooldown still returns None.""" + result = resolver._resolve_package_cooldown(None, 0) + assert result is None + + +def test_resolve_package_cooldown_override_days() -> None: + """Positive per-package override creates a new Cooldown with the given days.""" + result = resolver._resolve_package_cooldown(_COOLDOWN, 30) + assert result is not None + assert result.min_age.days == 30 + # bootstrap_time is inherited from the global cooldown for a consistent cutoff. + assert result.bootstrap_time == _COOLDOWN.bootstrap_time + + +def test_resolve_package_cooldown_override_no_global() -> None: + """Positive per-package override works even without a global cooldown.""" + result = resolver._resolve_package_cooldown(None, 14) + assert result is not None + assert result.min_age.days == 14 + + +def test_per_package_cooldown_disable_via_ctx(tmp_path: pathlib.Path) -> None: + """resolver_dist.min_release_age=0 disables cooldown for a specific package. + + Even when the global cooldown is active, a package with min_release_age=0 + in its settings should resolve the latest version. + """ + import yaml + + settings_dir = tmp_path / "settings" + settings_dir.mkdir() + # Disable cooldown for test-pkg specifically. + (settings_dir / "test-pkg.yaml").write_text( + yaml.dump({"resolver_dist": {"min_release_age": 0}}) + ) + + from fromager import packagesettings + + ctx = context.WorkContext( + active_settings=packagesettings.Settings.from_files( + settings_file=tmp_path / "settings.yaml", + settings_dir=settings_dir, + patches_dir=tmp_path / "patches", + variant="cpu", + max_jobs=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}, + ) + # With global cooldown active but per-package override=0, 2.0.0 (2 days + # old) should be selected because the cooldown is disabled for test-pkg. + _, 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) == "2.0.0" + + +# --------------------------------------------------------------------------- +# GitLab cooldown tests +# +# Mock data mirrors the submodlib fixture in test_resolver.py. Tag timestamps: +# v0.0.3 created_at: 2025-05-14T15:43:00Z (most recent) +# v0.0.2 tag created_at: null → falls back to commit created_at: +# 2025-04-14T14:41:32-05:00 → 2025-04-14T19:41:32Z (UTC) +# v0.0.1 created_at: 2025-04-14T19:04:20Z +# +# bootstrap_time = 2025-05-20T00:00:00Z, cooldown = 7 days +# cutoff = 2025-05-13T00:00:00Z +# → v0.0.3 (2025-05-14) is INSIDE the cooldown window (blocked) +# → v0.0.2 (2025-04-14) is OUTSIDE the cooldown window (allowed) +# → v0.0.1 (2025-04-14) is OUTSIDE the cooldown window (allowed) +# --------------------------------------------------------------------------- + +_GITLAB_BOOTSTRAP_TIME = datetime.datetime(2025, 5, 20, 0, 0, 0, tzinfo=datetime.UTC) +_GITLAB_COOLDOWN = Cooldown( + min_age=datetime.timedelta(days=7), + bootstrap_time=_GITLAB_BOOTSTRAP_TIME, +) + +_GITLAB_API_URL = "https://gitlab.com/api/v4/projects/test%2Fpkg/repository/tags" + +# Minimal GitLab tag API response with three versions at known timestamps. +_gitlab_tags_response = """ +[ + { + "name": "v0.0.3", + "message": "", + "target": "aaa", + "commit": { + "id": "aaa", + "created_at": "2025-04-24T00:00:00.000+00:00" + }, + "release": null, + "protected": false, + "created_at": "2025-05-14T15:43:00.000Z" + }, + { + "name": "v0.0.2", + "message": "", + "target": "bbb", + "commit": { + "id": "bbb", + "created_at": "2025-04-14T14:41:32.000-05:00" + }, + "release": null, + "protected": false, + "created_at": null + }, + { + "name": "v0.0.1", + "message": "", + "target": "ccc", + "commit": { + "id": "ccc", + "created_at": "2025-04-14T19:04:20.000+00:00" + }, + "release": null, + "protected": false, + "created_at": "2025-04-14T19:04:20.000Z" + } +] +""" + + +def _make_gitlab_provider(cooldown: Cooldown | None) -> resolver.GitLabTagProvider: + return resolver.GitLabTagProvider( + project_path="test/pkg", + server_url="https://gitlab.com", + matcher=re.compile(r"^v(.*)$"), + cooldown=cooldown, + use_resolver_cache=False, + ) + + +def test_gitlab_cooldown_filters_recent_tag( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """GitLabTagProvider rejects tags published within the cooldown window.""" + monkeypatch.setattr(resolver, "DEBUG_RESOLVER", "1") + with requests_mock.Mocker() as r: + r.get(_GITLAB_API_URL, text=_gitlab_tags_response) + provider = _make_gitlab_provider(_GITLAB_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"] + # v0.0.3 (2025-05-14) is inside the 7-day window; v0.0.2 is the next newest. + assert str(candidate.version) == "0.0.2" + assert "skipping 0.0.3" in caplog.text + assert "cooldown" in caplog.text + + +def test_gitlab_cooldown_disabled_selects_latest() -> None: + """Without a cooldown, GitLabTagProvider selects the latest tag.""" + with requests_mock.Mocker() as r: + r.get(_GITLAB_API_URL, text=_gitlab_tags_response) + provider = _make_gitlab_provider(cooldown=None) + rslvr = resolvelib.Resolver(provider, resolvelib.BaseReporter()) + result = rslvr.resolve([Requirement("test-pkg")]) + assert str(result.mapping["test-pkg"].version) == "0.0.3" + + +def test_gitlab_cooldown_no_upload_time_fails_closed( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A GitLab tag with no timestamp (tag and commit created_at both null) is + rejected fail-closed when a cooldown is active. + + GitLab structurally supports timestamps, so a missing one is treated as an + unverifiable candidate rather than an expected absence. + """ + monkeypatch.setattr(resolver, "DEBUG_RESOLVER", "1") + no_timestamp_response = """ +[ + { + "name": "v1.0.0", + "message": "", + "target": "ddd", + "commit": {"id": "ddd", "created_at": null}, + "release": null, + "protected": false, + "created_at": null + } +] +""" + with requests_mock.Mocker() as r: + r.get(_GITLAB_API_URL, text=no_timestamp_response) + provider = _make_gitlab_provider(_GITLAB_COOLDOWN) + rslvr = resolvelib.Resolver(provider, resolvelib.BaseReporter()) + + with caplog.at_level(logging.DEBUG, logger="fromager.resolver"): + with pytest.raises(resolvelib.resolvers.ResolverException): + rslvr.resolve([Requirement("test-pkg")]) + + assert "upload_time unknown" in caplog.text + + +def test_github_cooldown_skips_with_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """GitHubTagProvider skips the cooldown with a warning rather than failing closed. + + GitHub's tag API does not return commit timestamps, so all candidates have + upload_time=None. Failing closed would make cooldown unusable with any + GitHub-sourced package. Instead, a one-time warning is emitted and the + candidate is allowed through. + """ + provider = resolver.GitHubTagProvider( + organization="example", + repo="pkg", + cooldown=_GITLAB_COOLDOWN, + use_resolver_cache=False, + ) + candidate = resolver.Candidate( + name="test-pkg", + version=Version("1.0.0"), + url="https://github.com/example/pkg/archive/v1.0.0.tar.gz", + upload_time=None, + ) + req = Requirement("test-pkg") + requirements: typing.Any = defaultdict(list) + requirements["test-pkg"].append(req) + incompatibilities: typing.Any = defaultdict(list) + + with caplog.at_level(logging.WARNING, logger="fromager.resolver"): + result = provider.validate_candidate( + "test-pkg", requirements, incompatibilities, candidate + ) + + assert result is True + assert "cooldown cannot be enforced" in caplog.text + assert "not yet implemented" in caplog.text diff --git a/tests/test_packagesettings.py b/tests/test_packagesettings.py index 402eebb0..f8cdee7b 100644 --- a/tests/test_packagesettings.py +++ b/tests/test_packagesettings.py @@ -84,6 +84,7 @@ "sdist_server_url": "https://sdist.test/egg", "ignore_platform": True, "use_pypi_org_metadata": True, + "min_release_age": None, }, "variants": { "cpu": { @@ -145,6 +146,7 @@ "include_wheels": False, "ignore_platform": False, "use_pypi_org_metadata": None, + "min_release_age": None, }, "variants": {}, } @@ -185,6 +187,7 @@ "include_wheels": False, "ignore_platform": False, "use_pypi_org_metadata": None, + "min_release_age": None, }, "variants": { "cpu": {