1from __future__ import annotations
2
3import hashlib
4from collections.abc import Iterable
5from typing import TYPE_CHECKING, BinaryIO, NoReturn
6
7from pip._internal.exceptions import HashMismatch, HashMissing, InstallationError
8from pip._internal.utils.misc import read_chunks
9
10if TYPE_CHECKING:
11 from hashlib import _Hash
12
13
14# The recommended hash algo of the moment. Change this whenever the state of
15# the art changes; it won't hurt backward compatibility.
16FAVORITE_HASH = "sha256"
17
18
19# Names of hashlib algorithms allowed by the --hash option and ``pip hash``
20# Currently, those are the ones at least as collision-resistant as sha256.
21STRONG_HASHES = ["sha256", "sha384", "sha512"]
22
23
24class Hashes:
25 """A wrapper that builds multiple hashes at once and checks them against
26 known-good values
27
28 """
29
30 def __init__(self, hashes: dict[str, list[str]] | None = None) -> None:
31 """
32 :param hashes: A dict of algorithm names pointing to lists of allowed
33 hex digests
34 """
35 allowed = {}
36 if hashes is not None:
37 for alg, keys in hashes.items():
38 # Make sure values are always sorted (to ease equality checks)
39 allowed[alg] = [k.lower() for k in sorted(keys)]
40 self._allowed = allowed
41
42 def __and__(self, other: Hashes) -> Hashes:
43 if not isinstance(other, Hashes):
44 return NotImplemented
45
46 # If either of the Hashes object is entirely empty (i.e. no hash
47 # specified at all), all hashes from the other object are allowed.
48 if not other:
49 return self
50 if not self:
51 return other
52
53 # Otherwise only hashes that present in both objects are allowed.
54 new = {}
55 for alg, values in other._allowed.items():
56 if alg not in self._allowed:
57 continue
58 new[alg] = [v for v in values if v in self._allowed[alg]]
59 return Hashes(new)
60
61 @property
62 def digest_count(self) -> int:
63 return sum(len(digests) for digests in self._allowed.values())
64
65 def is_hash_allowed(self, hash_name: str, hex_digest: str) -> bool:
66 """Return whether the given hex digest is allowed."""
67 return hex_digest in self._allowed.get(hash_name, [])
68
69 def check_against_chunks(self, chunks: Iterable[bytes]) -> None:
70 """Check good hashes against ones built from iterable of chunks of
71 data.
72
73 Raise HashMismatch if none match.
74
75 """
76 gots = {}
77 for hash_name in self._allowed.keys():
78 try:
79 gots[hash_name] = hashlib.new(hash_name)
80 except (ValueError, TypeError):
81 raise InstallationError(f"Unknown hash name: {hash_name}")
82
83 for chunk in chunks:
84 for hash in gots.values():
85 hash.update(chunk)
86
87 for hash_name, got in gots.items():
88 if got.hexdigest() in self._allowed[hash_name]:
89 return
90 self._raise(gots)
91
92 def _raise(self, gots: dict[str, _Hash]) -> NoReturn:
93 raise HashMismatch(self._allowed, gots)
94
95 def check_against_file(self, file: BinaryIO) -> None:
96 """Check good hashes against a file-like object
97
98 Raise HashMismatch if none match.
99
100 """
101 return self.check_against_chunks(read_chunks(file))
102
103 def check_against_path(self, path: str) -> None:
104 with open(path, "rb") as file:
105 return self.check_against_file(file)
106
107 def has_one_of(self, hashes: dict[str, str]) -> bool:
108 """Return whether any of the given hashes are allowed."""
109 for hash_name, hex_digest in hashes.items():
110 if self.is_hash_allowed(hash_name, hex_digest):
111 return True
112 return False
113
114 def __bool__(self) -> bool:
115 """Return whether I know any known-good hashes."""
116 return bool(self._allowed)
117
118 def __eq__(self, other: object) -> bool:
119 if not isinstance(other, Hashes):
120 return NotImplemented
121 return self._allowed == other._allowed
122
123 def __hash__(self) -> int:
124 return hash(
125 ",".join(
126 sorted(
127 ":".join((alg, digest))
128 for alg, digest_list in self._allowed.items()
129 for digest in digest_list
130 )
131 )
132 )
133
134
135class MissingHashes(Hashes):
136 """A workalike for Hashes used when we're missing a hash for a requirement
137
138 It computes the actual hash of the requirement and raises a HashMissing
139 exception showing it to the user.
140
141 """
142
143 def __init__(self) -> None:
144 """Don't offer the ``hashes`` kwarg."""
145 # Pass our favorite hash in to generate a "gotten hash". With the
146 # empty list, it will never match, so an error will always raise.
147 super().__init__(hashes={FAVORITE_HASH: []})
148
149 def _raise(self, gots: dict[str, _Hash]) -> NoReturn:
150 raise HashMissing(gots[FAVORITE_HASH].hexdigest())