Skip to content

Commit 2c36f4d

Browse files
authored
Merge pull request #6699 from cjerdonek/issue-5874-hash-checking
Address #5874: Prefer candidates with allowed hashes
2 parents 34621bf + 74504ff commit 2c36f4d

File tree

7 files changed

+253
-34
lines changed

7 files changed

+253
-34
lines changed

news/5874.feature

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
When choosing candidates to install, prefer candidates with a hash matching
2+
one of the user-provided hashes.

src/pip/_internal/index.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,11 @@
5050
from pip._internal.req import InstallRequirement
5151
from pip._internal.download import PipSession
5252
from pip._internal.pep425tags import Pep425Tag
53+
from pip._internal.utils.hashes import Hashes
5354

5455
BuildTag = Tuple[Any, ...] # either empty tuple or Tuple[int, str]
5556
CandidateSortingKey = (
56-
Tuple[int, int, _BaseVersion, BuildTag, Optional[int]]
57+
Tuple[int, int, int, _BaseVersion, BuildTag, Optional[int]]
5758
)
5859
HTMLElement = xml.etree.ElementTree.Element
5960
SecureOrigin = Tuple[str, str, Optional[str]]
@@ -440,6 +441,45 @@ def evaluate_link(self, link):
440441
return (True, version)
441442

442443

444+
def filter_unallowed_hashes(
445+
candidates, # type: List[InstallationCandidate]
446+
hashes, # type: Hashes
447+
):
448+
# type: (...) -> List[InstallationCandidate]
449+
"""
450+
Filter out candidates whose hashes aren't allowed, and return a new
451+
list of candidates.
452+
453+
If at least one candidate has an allowed hash, then all candidates with
454+
either an allowed hash or no hash specified are returned. Otherwise,
455+
the given candidates are returned.
456+
457+
Including the candidates with no hash specified when there is a match
458+
allows a warning to be logged if there is a more preferred candidate
459+
with no hash specified. Returning all candidates in the case of no
460+
matches lets pip report the hash of the candidate that would otherwise
461+
have been installed (e.g. permitting the user to more easily update
462+
their requirements file with the desired hash).
463+
"""
464+
applicable = []
465+
found_allowed_hash = False
466+
for candidate in candidates:
467+
link = candidate.location
468+
if not link.has_hash:
469+
applicable.append(candidate)
470+
continue
471+
472+
if link.is_hash_allowed(hashes=hashes):
473+
found_allowed_hash = True
474+
applicable.append(candidate)
475+
476+
if found_allowed_hash:
477+
return applicable
478+
479+
# Make sure we're not returning back the given value.
480+
return list(candidates)
481+
482+
443483
class CandidatePreferences(object):
444484

445485
"""
@@ -473,13 +513,15 @@ def create(
473513
target_python=None, # type: Optional[TargetPython]
474514
prefer_binary=False, # type: bool
475515
allow_all_prereleases=False, # type: bool
516+
hashes=None, # type: Optional[Hashes]
476517
):
477518
# type: (...) -> CandidateEvaluator
478519
"""Create a CandidateEvaluator object.
479520
480521
:param target_python: The target Python interpreter to use when
481522
checking compatibility. If None (the default), a TargetPython
482523
object will be constructed from the running Python.
524+
:param hashes: An optional collection of allowed hashes.
483525
"""
484526
if target_python is None:
485527
target_python = TargetPython()
@@ -490,20 +532,23 @@ def create(
490532
supported_tags=supported_tags,
491533
prefer_binary=prefer_binary,
492534
allow_all_prereleases=allow_all_prereleases,
535+
hashes=hashes,
493536
)
494537

495538
def __init__(
496539
self,
497540
supported_tags, # type: List[Pep425Tag]
498541
prefer_binary=False, # type: bool
499542
allow_all_prereleases=False, # type: bool
543+
hashes=None, # type: Optional[Hashes]
500544
):
501545
# type: (...) -> None
502546
"""
503547
:param supported_tags: The PEP 425 tags supported by the target
504548
Python in order of preference (most preferred first).
505549
"""
506550
self._allow_all_prereleases = allow_all_prereleases
551+
self._hashes = hashes
507552
self._prefer_binary = prefer_binary
508553
self._supported_tags = supported_tags
509554

@@ -536,7 +581,10 @@ def get_applicable_candidates(
536581
applicable_candidates = [
537582
c for c in candidates if str(c.version) in versions
538583
]
539-
return applicable_candidates
584+
585+
return filter_unallowed_hashes(
586+
candidates=applicable_candidates, hashes=self._hashes,
587+
)
540588

541589
def make_found_candidates(
542590
self,
@@ -576,8 +624,14 @@ def _sort_key(self, candidate):
576624
577625
The preference is as follows:
578626
579-
First and foremost, yanked candidates (in the sense of PEP 592) are
580-
always less preferred than candidates that haven't been yanked. Then:
627+
First and foremost, candidates with allowed (matching) hashes are
628+
always preferred over candidates without matching hashes. This is
629+
because e.g. if the only candidate with an allowed hash is yanked,
630+
we still want to use that candidate.
631+
632+
Second, excepting hash considerations, candidates that have been
633+
yanked (in the sense of PEP 592) are always less preferred than
634+
candidates that haven't been yanked. Then:
581635
582636
If not finding wheels, they are sorted by version only.
583637
If finding wheels, then the sort order is by version, then:
@@ -612,9 +666,11 @@ def _sort_key(self, candidate):
612666
build_tag = (int(build_tag_groups[0]), build_tag_groups[1])
613667
else: # sdist
614668
pri = -(support_num)
669+
has_allowed_hash = int(link.is_hash_allowed(self._hashes))
615670
yank_value = -1 * int(link.is_yanked) # -1 for yanked.
616671
return (
617-
yank_value, binary_preference, candidate.version, build_tag, pri,
672+
has_allowed_hash, yank_value, binary_preference, candidate.version,
673+
build_tag, pri,
618674
)
619675

620676
def get_best_candidate(
@@ -1049,21 +1105,23 @@ def find_all_candidates(self, project_name):
10491105
# This is an intentional priority ordering
10501106
return file_versions + find_links_versions + page_versions
10511107

1052-
def make_candidate_evaluator(self):
1053-
# type: (...) -> CandidateEvaluator
1108+
def make_candidate_evaluator(self, hashes=None):
1109+
# type: (Optional[Hashes]) -> CandidateEvaluator
10541110
"""Create a CandidateEvaluator object to use.
10551111
"""
10561112
candidate_prefs = self._candidate_prefs
10571113
return CandidateEvaluator.create(
10581114
target_python=self._target_python,
10591115
prefer_binary=candidate_prefs.prefer_binary,
10601116
allow_all_prereleases=candidate_prefs.allow_all_prereleases,
1117+
hashes=hashes,
10611118
)
10621119

10631120
def find_candidates(
10641121
self,
10651122
project_name, # type: str
10661123
specifier=None, # type: Optional[specifiers.BaseSpecifier]
1124+
hashes=None, # type: Optional[Hashes]
10671125
):
10681126
# type: (...) -> FoundCandidates
10691127
"""Find matches for the given project and specifier.
@@ -1075,7 +1133,7 @@ def find_candidates(
10751133
:return: A `FoundCandidates` instance.
10761134
"""
10771135
candidates = self.find_all_candidates(project_name)
1078-
candidate_evaluator = self.make_candidate_evaluator()
1136+
candidate_evaluator = self.make_candidate_evaluator(hashes=hashes)
10791137
return candidate_evaluator.make_found_candidates(
10801138
candidates, specifier=specifier,
10811139
)
@@ -1088,7 +1146,10 @@ def find_requirement(self, req, upgrade):
10881146
Returns a Link if found,
10891147
Raises DistributionNotFound or BestVersionAlreadyInstalled otherwise
10901148
"""
1091-
candidates = self.find_candidates(req.name, req.specifier)
1149+
hashes = req.hashes(trust_internet=False)
1150+
candidates = self.find_candidates(
1151+
req.name, specifier=req.specifier, hashes=hashes,
1152+
)
10921153
best_candidate = candidates.get_best()
10931154

10941155
installed_version = None # type: Optional[_BaseVersion]

src/pip/_internal/models/link.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
if MYPY_CHECK_RUNNING:
1414
from typing import Optional, Text, Tuple, Union
1515
from pip._internal.index import HTMLPage
16+
from pip._internal.utils.hashes import Hashes
1617

1718

1819
class Link(KeyBasedCompareMixin):
@@ -193,3 +194,17 @@ def is_artifact(self):
193194
def is_yanked(self):
194195
# type: () -> bool
195196
return self.yanked_reason is not None
197+
198+
@property
199+
def has_hash(self):
200+
return self.hash_name is not None
201+
202+
def is_hash_allowed(self, hashes):
203+
# type: (Hashes) -> bool
204+
"""
205+
Return True if the link has a hash and it is allowed.
206+
"""
207+
if not self.has_hash:
208+
return False
209+
210+
return hashes.is_hash_allowed(self.hash_name, hex_digest=self.hash)

src/pip/_internal/utils/hashes.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ def __init__(self, hashes=None):
4444
"""
4545
self._allowed = {} if hashes is None else hashes
4646

47+
def is_hash_allowed(
48+
self,
49+
hash_name, # type: str
50+
hex_digest, # type: str
51+
):
52+
"""Return whether the given hex digest is allowed."""
53+
return hex_digest in self._allowed.get(hash_name, [])
54+
4755
def check_against_chunks(self, chunks):
4856
# type: (Iterator[bytes]) -> None
4957
"""Check good hashes against ones built from iterable of chunks of

0 commit comments

Comments
 (0)