Skip to content

Commit 84bf64e

Browse files
committed
feat: add a solver.min-release-age config option
1 parent bfce511 commit 84bf64e

14 files changed

Lines changed: 400 additions & 74 deletions

File tree

docs/configuration.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,34 @@ Especially with slow network connections, this setting can speed up dependency r
395395
If the cache has already been filled or the server does not support HTTP range requests,
396396
this setting makes no difference.
397397

398+
### `solver.min-release-age`
399+
400+
**Type**: `int`
401+
402+
**Default**: `0`
403+
404+
**Environment Variable**: `POETRY_SOLVER_MIN_RELEASE_AGE`
405+
406+
*Introduced in 2.4.0*
407+
408+
Minimum age of a package release in **days** before it is considered during dependency resolution.
409+
When set, any package version where at least one distribution file was uploaded more recently
410+
than the specified number of days ago will be ignored by the solver.
411+
412+
For example, with a value of `7`, a version is only considered
413+
if all known distribution files are at least seven days old.
414+
If the option is not set or set to `0`, all versions are considered.
415+
416+
This option is useful to protect against supply chain attacks where a new release
417+
of a dependency is published with malicious code.
418+
This is often detected within hours or days and the compromised release is removed.
419+
420+
{{% note %}}
421+
This filter can only be enforced for package sources that expose file upload timestamps.
422+
If a source does not provide upload times for a release,
423+
that release is not filtered out by this setting.
424+
{{% /note %}}
425+
398426
### `system-git-client`
399427

400428
**Type**: `boolean`

src/poetry/config/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ class Config:
174174
"python": {"installation-dir": os.path.join("{data-dir}", "python")},
175175
"solver": {
176176
"lazy-wheel": True,
177+
"min-release-age": 0,
177178
},
178179
"system-git-client": False,
179180
"keyring": {
@@ -398,6 +399,7 @@ def _get_normalizer(name: str) -> Callable[[str], Any]:
398399
if name in {
399400
"installer.max-workers",
400401
"requests.max-retries",
402+
"solver.min-release-age",
401403
}:
402404
return int_normalizer
403405

src/poetry/console/commands/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ def unique_config_values(self) -> dict[str, tuple[Any, Any]]:
102102
PackageFilterPolicy.normalize,
103103
),
104104
"solver.lazy-wheel": (boolean_validator, boolean_normalizer),
105+
"solver.min-release-age": (lambda val: int(val) >= 0, int_normalizer),
105106
"keyring.enabled": (boolean_validator, boolean_normalizer),
106107
"python.installation-dir": (str, lambda val: str(Path(val))),
107108
}

src/poetry/puzzle/solver.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -86,28 +86,34 @@ def solve(
8686
) -> Transaction:
8787
from poetry.puzzle.transaction import Transaction
8888

89-
with self._progress(), self._provider.use_latest_for(use_latest or []):
90-
start = time.time()
91-
packages = self._solve()
92-
# simplify markers by removing redundant information
93-
for transitive_info in packages.values():
94-
for group, marker in transitive_info.markers.items():
95-
transitive_info.markers[group] = simplify_marker(
96-
marker, self._package.python_constraint
89+
try:
90+
with self._progress(), self._provider.use_latest_for(use_latest or []):
91+
start = time.time()
92+
packages = self._solve()
93+
# simplify markers by removing redundant information
94+
for transitive_info in packages.values():
95+
for group, marker in transitive_info.markers.items():
96+
transitive_info.markers[group] = simplify_marker(
97+
marker, self._package.python_constraint
98+
)
99+
end = time.time()
100+
101+
if len(self._overrides) > 1:
102+
self._provider.debug(
103+
# ignore the warning as provider does not do interpolation
104+
f"Complete version solving took {end - start:.3f}"
105+
f" seconds with {len(self._overrides)} overrides"
97106
)
98-
end = time.time()
107+
self._provider.debug(
108+
# ignore the warning as provider does not do interpolation
109+
"Resolved with overrides:"
110+
f" {', '.join(f'({b})' for b in self._overrides)}"
111+
)
112+
except SolverProblemError:
113+
self._pool.log_age_filtered_versions(level="warning")
114+
raise
99115

100-
if len(self._overrides) > 1:
101-
self._provider.debug(
102-
# ignore the warning as provider does not do interpolation
103-
f"Complete version solving took {end - start:.3f}"
104-
f" seconds with {len(self._overrides)} overrides"
105-
)
106-
self._provider.debug(
107-
# ignore the warning as provider does not do interpolation
108-
"Resolved with overrides:"
109-
f" {', '.join(f'({b})' for b in self._overrides)}"
110-
)
116+
self._pool.log_age_filtered_versions(level="info")
111117

112118
for p in packages:
113119
if p.yanked:

src/poetry/repositories/http_repository.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

66
from contextlib import contextmanager
77
from contextlib import suppress
8+
from datetime import datetime
9+
from datetime import timedelta
10+
from datetime import timezone
811
from pathlib import Path
912
from tempfile import TemporaryDirectory
1013
from typing import TYPE_CHECKING
@@ -14,6 +17,8 @@
1417
import requests.adapters
1518

1619
from packaging.metadata import parse_email
20+
from poetry.core.constraints.version import Version
21+
from poetry.core.constraints.version import VersionConstraint
1722
from poetry.core.constraints.version import parse_constraint
1823
from poetry.core.packages.dependency import Dependency
1924
from poetry.core.version.markers import parse_marker
@@ -36,9 +41,11 @@
3641

3742

3843
if TYPE_CHECKING:
44+
from collections.abc import Iterable
3945
from collections.abc import Iterator
4046

4147
from packaging.utils import NormalizedName
48+
from poetry.core.packages.package import Package
4249
from poetry.core.packages.package import PackageFile
4350
from poetry.core.packages.utils.link import Link
4451

@@ -71,6 +78,14 @@ def __init__(
7178

7279
self._lazy_wheel = config.get("solver.lazy-wheel", True)
7380
self._max_retries = config.get("requests.max-retries", 0)
81+
82+
self._min_release_age = config.get("solver.min-release-age", 0)
83+
self._min_release_age_cutoff: datetime | None = None
84+
if self._min_release_age:
85+
self._min_release_age_cutoff = datetime.now(tz=timezone.utc) - timedelta(
86+
days=self._min_release_age
87+
)
88+
self._age_filtered_versions: dict[NormalizedName, set[Version]] = {}
7489
# We are tracking if a domain supports range requests or not to avoid
7590
# unnecessary requests.
7691
# ATTENTION: A domain might support range requests only for some files, so the
@@ -119,6 +134,64 @@ def _cached_or_downloaded_file(
119134
)
120135
yield filepath
121136

137+
def _package(
138+
self, name: NormalizedName, version: Version, yanked: str | bool
139+
) -> Package:
140+
raise NotImplementedError
141+
142+
def _is_version_too_recent(self, links: Iterable[Link]) -> bool:
143+
"""Return True if any file of the version was uploaded after the cutoff.
144+
145+
If no upload time information is available for any file,
146+
the version is considered old enough (return False).
147+
"""
148+
if not self._min_release_age_cutoff:
149+
return False
150+
for link in links:
151+
upload_time = link.upload_time
152+
if upload_time is None:
153+
continue
154+
if upload_time > self._min_release_age_cutoff:
155+
return True
156+
return False
157+
158+
def _find_packages(
159+
self, name: NormalizedName, constraint: VersionConstraint
160+
) -> list[Package]:
161+
"""
162+
Find packages on the remote server.
163+
"""
164+
try:
165+
page = self.get_page(name)
166+
except PackageNotFoundError:
167+
self._log(f"No packages found for {name}", level="debug")
168+
return []
169+
170+
versions = [
171+
(version, page.yanked(name, version))
172+
for version in page.versions(name)
173+
if constraint.allows(version)
174+
]
175+
176+
if self._min_release_age_cutoff is not None:
177+
filtered_out: set[Version] = set()
178+
accepted: list[tuple[Version, str | bool]] = []
179+
for version, yanked in versions:
180+
if self._is_version_too_recent(page.links_for_version(name, version)):
181+
filtered_out.add(version)
182+
else:
183+
accepted.append((version, yanked))
184+
if filtered_out:
185+
self._age_filtered_versions[name] = filtered_out
186+
self._log(
187+
f"Ignoring {name} version(s) due to "
188+
f"solver.min-release-age={self._min_release_age}: {versions}",
189+
level="debug",
190+
)
191+
versions = accepted
192+
193+
return [self._package(name, version, yanked) for version, yanked in versions]
194+
122195
def _get_info_from_wheel(self, link: Link) -> PackageInfo:
123196
from poetry.inspection.info import PackageInfo
124197

@@ -477,3 +550,17 @@ def _get_page(self, name: NormalizedName) -> LinkSource:
477550
if self._is_json_response(response):
478551
return SimpleJsonPage(response.url, response.json())
479552
return HTMLPage(response.url, response.text)
553+
554+
def log_age_filtered_versions(self, *, level: str) -> None:
555+
if not self._age_filtered_versions:
556+
return
557+
self._log(
558+
"The following packages versions were ignored"
559+
f" due to solver.min-release-age={self._min_release_age}",
560+
level=level,
561+
)
562+
for name in sorted(self._age_filtered_versions):
563+
versions = ", ".join(
564+
str(v) for v in sorted(self._age_filtered_versions[name])
565+
)
566+
self._log(f"{name}: {versions}", level=level)

src/poetry/repositories/legacy_repository.py

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
if TYPE_CHECKING:
2121
from packaging.utils import NormalizedName
2222
from poetry.core.constraints.version import Version
23-
from poetry.core.constraints.version import VersionConstraint
2423
from poetry.core.packages.utils.link import Link
2524

2625
from poetry.config.config import Config
@@ -79,35 +78,17 @@ def find_links_for_package(self, package: Package) -> list[Link]:
7978

8079
return list(page.links_for_version(package.name, package.version))
8180

82-
def _find_packages(
83-
self, name: NormalizedName, constraint: VersionConstraint
84-
) -> list[Package]:
85-
"""
86-
Find packages on the remote server.
87-
"""
88-
try:
89-
page = self.get_page(name)
90-
except PackageNotFoundError:
91-
self._log(f"No packages found for {name}", level="debug")
92-
return []
93-
94-
versions = [
95-
(version, page.yanked(name, version))
96-
for version in page.versions(name)
97-
if constraint.allows(version)
98-
]
99-
100-
return [
101-
Package(
102-
name,
103-
version,
104-
source_type="legacy",
105-
source_reference=self.name,
106-
source_url=self._url,
107-
yanked=yanked,
108-
)
109-
for version, yanked in versions
110-
]
81+
def _package(
82+
self, name: NormalizedName, version: Version, yanked: str | bool
83+
) -> Package:
84+
return Package(
85+
name,
86+
version,
87+
source_type="legacy",
88+
source_reference=self.name,
89+
source_url=self._url,
90+
yanked=yanked,
91+
)
11192

11293
def _get_release_info(
11394
self, name: NormalizedName, version: Version

src/poetry/repositories/pypi_repository.py

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
if TYPE_CHECKING:
3131
from packaging.utils import NormalizedName
3232
from poetry.core.constraints.version import Version
33-
from poetry.core.constraints.version import VersionConstraint
3433

3534
from poetry.config.config import Config
3635

@@ -102,25 +101,10 @@ def get_package_info(self, name: NormalizedName) -> dict[str, Any]:
102101
"""
103102
return self._get_package_info(name)
104103

105-
def _find_packages(
106-
self, name: NormalizedName, constraint: VersionConstraint
107-
) -> list[Package]:
108-
"""
109-
Find packages on the remote server.
110-
"""
111-
try:
112-
json_page = self.get_page(name)
113-
except PackageNotFoundError:
114-
self._log(f"No packages found for {name}", level="debug")
115-
return []
116-
117-
versions = [
118-
(version, json_page.yanked(name, version))
119-
for version in json_page.versions(name)
120-
if constraint.allows(version)
121-
]
122-
123-
return [Package(name, version, yanked=yanked) for version, yanked in versions]
104+
def _package(
105+
self, name: NormalizedName, version: Version, yanked: str | bool
106+
) -> Package:
107+
return Package(name, version, yanked=yanked)
124108

125109
def _get_package_info(self, name: NormalizedName) -> dict[str, Any]:
126110
headers = {"Accept": "application/vnd.pypi.simple.v1+json"}

src/poetry/repositories/repository.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,6 @@ def package(self, name: str, version: Version) -> Package:
109109
return package
110110

111111
raise PackageNotFoundError(f"Package {name} ({version}) not found.")
112+
113+
def log_age_filtered_versions(self, *, level: str) -> None:
114+
pass

src/poetry/repositories/repository_pool.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,7 @@ def refresh(self, package: Package) -> Package:
189189
if isinstance(repo, CachedRepository):
190190
repo.forget(package.name, package.version)
191191
return repo.package(package.name, package.version)
192+
193+
def log_age_filtered_versions(self, *, level: str) -> None:
194+
for repo in self.repositories:
195+
repo.log_age_filtered_versions(level=level)

tests/config/test_config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,10 @@ def get_options_based_on_normalizer(normalizer: Normalizer) -> Iterator[str]:
4343
("installer.parallel", True),
4444
("virtualenvs.create", True),
4545
("requests.max-retries", 0),
46+
("solver.min-release-age", 0),
4647
],
4748
)
48-
def test_config_get_default_value(config: Config, name: str, value: bool) -> None:
49+
def test_config_get_default_value(config: Config, name: str, value: bool | int) -> None:
4950
assert config.get(name) is value
5051

5152

0 commit comments

Comments
 (0)