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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,73 @@ Especially with slow network connections, this setting can speed up dependency r
If the cache has already been filled or the server does not support HTTP range requests,
this setting makes no difference.

### `solver.min-release-age`

**Type**: `int`

**Default**: `0`

**Environment Variable**: `POETRY_SOLVER_MIN_RELEASE_AGE`

*Introduced in 2.4.0*

Minimum age of a package release in **days** before it is considered during dependency resolution.
When set, any package version where at least one distribution file was uploaded more recently
than the specified number of days ago will be ignored by the solver.

For example, with a value of `7`, a version is only considered
if all known distribution files are at least seven days old.
If the option is not set or set to `0`, all versions are considered.

This option is useful to protect against supply chain attacks where a new release
of a dependency is published with malicious code.
This is often detected within hours or days and the compromised release is removed.

{{% note %}}
This filter can only be enforced for package sources that expose file upload timestamps.
If a source does not provide upload times for a release,
that release is not filtered out by this setting.
{{% /note %}}

### `solver.min-release-age-exclude`

**Type**: `string`

**Default**: *not set*

**Environment Variable**: `POETRY_SOLVER_MIN_RELEASE_AGE_EXCLUDE`

*Introduced in 2.4.0*

A comma-separated list of package names that should be excluded from the
[`solver.min-release-age`](#solvermin-release-age) filter.
Versions of these packages will always be considered by the solver,
regardless of their upload age.

```bash
poetry config solver.min-release-age-exclude "my-package,other-package"
```

### `solver.min-release-age-exclude-source`

**Type**: `string`

**Default**: *not set*

**Environment Variable**: `POETRY_SOLVER_MIN_RELEASE_AGE_EXCLUDE_SOURCE`

*Introduced in 2.4.0*

A comma-separated list of source names or URLs that should be excluded from the
[`solver.min-release-age`](#solvermin-release-age) filter.
All packages from these sources will always be considered by the solver,
regardless of their upload age.
Sources can be referenced by the name defined in `pyproject.toml` or by URL.

```bash
poetry config solver.min-release-age-exclude-source "private-repo,https://example.com/simple/"
```

### `system-git-client`

**Type**: `boolean`
Expand Down
22 changes: 18 additions & 4 deletions src/poetry/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ def int_normalizer(val: str) -> int:
return int(val)


def str_list_normalizer(val: str) -> list[str]:
return [vs for v in val.split(",") if (vs := v.strip())]


def build_config_setting_validator(val: str) -> bool:
try:
value = build_config_setting_normalizer(val)
Expand Down Expand Up @@ -114,9 +118,8 @@ def normalize(cls, policy: str) -> list[str]:

return list(
{
name.strip() if cls.is_reserved(name) else canonicalize_name(name)
for name in policy.strip().split(",")
if name
name if cls.is_reserved(name) else canonicalize_name(name)
for name in str_list_normalizer(policy)
}
)

Expand Down Expand Up @@ -174,6 +177,9 @@ class Config:
"python": {"installation-dir": os.path.join("{data-dir}", "python")},
"solver": {
"lazy-wheel": True,
"min-release-age": 0,
"min-release-age-exclude": None,
"min-release-age-exclude-source": None,
},
"system-git-client": False,
"keyring": {
Expand Down Expand Up @@ -398,12 +404,20 @@ def _get_normalizer(name: str) -> Callable[[str], Any]:
if name in {
"installer.max-workers",
"requests.max-retries",
"solver.min-release-age",
}:
return int_normalizer

if name in ["installer.no-binary", "installer.only-binary"]:
if name in {
"installer.no-binary",
"installer.only-binary",
"solver.min-release-age-exclude",
}:
return PackageFilterPolicy.normalize

if name == "solver.min-release-age-exclude-source":
return str_list_normalizer

if name.startswith("installer.build-config-settings."):
return build_config_setting_normalizer

Expand Down
10 changes: 10 additions & 0 deletions src/poetry/console/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from poetry.config.config import build_config_setting_normalizer
from poetry.config.config import build_config_setting_validator
from poetry.config.config import int_normalizer
from poetry.config.config import str_list_normalizer
from poetry.config.config_source import UNSET
from poetry.config.config_source import ConfigSourceMigration
from poetry.config.config_source import PropertyNotFoundError
Expand Down Expand Up @@ -102,6 +103,15 @@ def unique_config_values(self) -> dict[str, tuple[Any, Any]]:
PackageFilterPolicy.normalize,
),
"solver.lazy-wheel": (boolean_validator, boolean_normalizer),
"solver.min-release-age": (lambda val: int(val) >= 0, int_normalizer),
"solver.min-release-age-exclude": (
PackageFilterPolicy.validator,
PackageFilterPolicy.normalize,
),
"solver.min-release-age-exclude-source": (
lambda val: bool(val.strip()),
str_list_normalizer,
),
"keyring.enabled": (boolean_validator, boolean_normalizer),
"python.installation-dir": (str, lambda val: str(Path(val))),
}
Expand Down
46 changes: 26 additions & 20 deletions src/poetry/puzzle/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,28 +86,34 @@ def solve(
) -> Transaction:
from poetry.puzzle.transaction import Transaction

with self._progress(), self._provider.use_latest_for(use_latest or []):
start = time.time()
packages = self._solve()
# simplify markers by removing redundant information
for transitive_info in packages.values():
for group, marker in transitive_info.markers.items():
transitive_info.markers[group] = simplify_marker(
marker, self._package.python_constraint
try:
with self._progress(), self._provider.use_latest_for(use_latest or []):
start = time.time()
packages = self._solve()
# simplify markers by removing redundant information
for transitive_info in packages.values():
for group, marker in transitive_info.markers.items():
transitive_info.markers[group] = simplify_marker(
marker, self._package.python_constraint
)
end = time.time()

if len(self._overrides) > 1:
self._provider.debug(
# ignore the warning as provider does not do interpolation
f"Complete version solving took {end - start:.3f}"
f" seconds with {len(self._overrides)} overrides"
)
end = time.time()
self._provider.debug(
# ignore the warning as provider does not do interpolation
"Resolved with overrides:"
f" {', '.join(f'({b})' for b in self._overrides)}"
)
except SolverProblemError:
self._pool.log_age_filtered_versions(level="warning", reset=True)
raise

if len(self._overrides) > 1:
self._provider.debug(
# ignore the warning as provider does not do interpolation
f"Complete version solving took {end - start:.3f}"
f" seconds with {len(self._overrides)} overrides"
)
self._provider.debug(
# ignore the warning as provider does not do interpolation
"Resolved with overrides:"
f" {', '.join(f'({b})' for b in self._overrides)}"
)
self._pool.log_age_filtered_versions(level="info", reset=True)

for p in packages:
if p.yanked:
Expand Down
116 changes: 116 additions & 0 deletions src/poetry/repositories/http_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
import functools
import hashlib

from collections import defaultdict
from contextlib import contextmanager
from contextlib import suppress
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING
Expand All @@ -14,6 +18,9 @@
import requests.adapters

from packaging.metadata import parse_email
from packaging.utils import canonicalize_name
from poetry.core.constraints.version import Version
from poetry.core.constraints.version import VersionConstraint
from poetry.core.constraints.version import parse_constraint
from poetry.core.packages.dependency import Dependency
from poetry.core.version.markers import parse_marker
Expand All @@ -36,9 +43,11 @@


if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Iterator

from packaging.utils import NormalizedName
from poetry.core.packages.package import Package
from poetry.core.packages.package import PackageFile
from poetry.core.packages.utils.link import Link

Expand Down Expand Up @@ -71,6 +80,29 @@ def __init__(

self._lazy_wheel = config.get("solver.lazy-wheel", True)
self._max_retries = config.get("requests.max-retries", 0)

self._min_release_age = config.get("solver.min-release-age", 0)
self._min_release_age_cutoff: datetime | None = None
self._min_release_age_exclude: set[NormalizedName] = set()
if self._min_release_age:
exclude_sources: set[str] = set(
config.get("solver.min-release-age-exclude-source") or []
)
if self._is_name_excluded_from_min_release_age(
exclude_sources
) or self._is_url_excluded_from_min_release_age(exclude_sources):
self._min_release_age = 0
else:
self._min_release_age_cutoff = datetime.now(
tz=timezone.utc
) - timedelta(days=self._min_release_age)
self._min_release_age_exclude = {
canonicalize_name(n)
for n in (config.get("solver.min-release-age-exclude") or [])
}
self._age_filtered_versions: defaultdict[NormalizedName, set[Version]] = (
defaultdict(set)
)
# We are tracking if a domain supports range requests or not to avoid
# unnecessary requests.
# ATTENTION: A domain might support range requests only for some files, so the
Expand All @@ -80,6 +112,12 @@ def __init__(
# - False: The domain does not support range requests for the files we tried.
self._supports_range_requests: dict[str, bool] = {}

def _is_name_excluded_from_min_release_age(self, exclude_sources: set[str]) -> bool:
return self.name.lower() in {s.lower() for s in exclude_sources}

def _is_url_excluded_from_min_release_age(self, exclude_sources: set[str]) -> bool:
return self.url.rstrip("/") in {s.rstrip("/") for s in exclude_sources}

@property
def session(self) -> Authenticator:
return self._authenticator
Expand Down Expand Up @@ -119,6 +157,68 @@ def _cached_or_downloaded_file(
)
yield filepath

def _package(
self, name: NormalizedName, version: Version, yanked: str | bool
) -> Package:
raise NotImplementedError

def _is_version_too_recent(self, links: Iterable[Link]) -> bool:
"""Return True if any file of the version was uploaded after the cutoff.

If no upload time information is available for any file,
the version is considered old enough (return False).
"""
if not self._min_release_age_cutoff:
return False
for link in links:
upload_time = link.upload_time
if upload_time is None:
continue
if upload_time > self._min_release_age_cutoff:
return True
return False

def _find_packages(
self, name: NormalizedName, constraint: VersionConstraint
) -> list[Package]:
"""
Find packages on the remote server.
"""
try:
page = self.get_page(name)
except PackageNotFoundError:
self._log(f"No packages found for {name}", level="debug")
return []

versions = [
(version, page.yanked(name, version))
for version in page.versions(name)
if constraint.allows(version)
]

if (
self._min_release_age_cutoff is not None
and name not in self._min_release_age_exclude
):
filtered_out: set[Version] = set()
accepted: list[tuple[Version, str | bool]] = []
for version, yanked in versions:
if self._is_version_too_recent(page.links_for_version(name, version)):
filtered_out.add(version)
else:
accepted.append((version, yanked))
if filtered_out:
self._age_filtered_versions[name] |= filtered_out
version_list = ", ".join(str(v) for v in sorted(filtered_out))
self._log(
f"Ignoring {name} version(s) due to "
f"solver.min-release-age={self._min_release_age}: {version_list}",
level="debug",
)
versions = accepted

return [self._package(name, version, yanked) for version, yanked in versions]

def _get_info_from_wheel(self, link: Link) -> PackageInfo:
from poetry.inspection.info import PackageInfo

Expand Down Expand Up @@ -477,3 +577,19 @@ def _get_page(self, name: NormalizedName) -> LinkSource:
if self._is_json_response(response):
return SimpleJsonPage(response.url, response.json())
return HTMLPage(response.url, response.text)

def log_age_filtered_versions(self, *, level: str, reset: bool) -> None:
if not self._age_filtered_versions:
return
self._log(
"The following package versions were ignored"
f" due to solver.min-release-age={self._min_release_age}",
level=level,
)
for name in sorted(self._age_filtered_versions):
versions = ", ".join(
str(v) for v in sorted(self._age_filtered_versions[name])
)
self._log(f"{name}: {versions}", level=level)
if reset:
self._age_filtered_versions.clear()
Loading
Loading