diff --git a/CHANGELOG.md b/CHANGELOG.md index e28cd2cf..399b0749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ All versions prior to 0.0.9 are untracked. ## [Unreleased] - ReleaseDate +### Fixed + +* Dependency sources: A bug caused by ambiguous parses of source distribution + files was fixed ([#249](https://github.com/trailofbits/pip-audit/pull/249)) + ## [2.1.0] - 2022-03-11 ### Added diff --git a/pip_audit/_dependency_source/resolvelib/pypi_provider.py b/pip_audit/_dependency_source/resolvelib/pypi_provider.py index e10ecfe5..e5e7a626 100644 --- a/pip_audit/_dependency_source/resolvelib/pypi_provider.py +++ b/pip_audit/_dependency_source/resolvelib/pypi_provider.py @@ -310,6 +310,13 @@ def find_matches(self, identifier, requirements, incompatibilities): ) if candidate.version not in bad_versions and all(candidate.version in r.specifier for r in requirements) + # HACK(ww): Additionally check that each candidate's name matches the + # expected project name (identifier). + # This technically shouldn't be required, but parsing distribution names + # from package indices is imprecise/unreliable when distribution filenames + # are PEP 440 compliant but not normalized. + # See: https://github.com/pypa/packaging/issues/527 + and candidate.name == identifier ], key=attrgetter("version", "is_wheel"), reverse=True, @@ -330,8 +337,6 @@ def is_satisfied_by(self, requirement, candidate): """ See `resolvelib.providers.AbstractProvider.is_satisfied_by`. """ - if canonicalize_name(requirement.name) != candidate.name: - return False return candidate.version in requirement.specifier def get_dependencies(self, candidate): diff --git a/setup.py b/setup.py index 7f6626cb..dc108787 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,8 @@ "bump >= 1.3.1", "flake8", "black", + # See: https://github.com/psf/black/issues/2964 + "click >= 8.0.0, < 8.1.0", "isort", "pytest", "pytest-cov", diff --git a/test/dependency_source/test_resolvelib.py b/test/dependency_source/test_resolvelib.py index 5ac27b76..189f68ed 100644 --- a/test/dependency_source/test_resolvelib.py +++ b/test/dependency_source/test_resolvelib.py @@ -7,7 +7,7 @@ from packaging.version import Version from pip_api import Requirement as ParsedRequirement from requests.exceptions import HTTPError -from resolvelib.resolvers import InconsistentCandidate, ResolutionImpossible +from resolvelib.resolvers import ResolutionImpossible from pip_audit._dependency_source import resolvelib from pip_audit._dependency_source.resolvelib import pypi_provider @@ -172,6 +172,34 @@ def test_resolvelib_sdist_patched(monkeypatch, suffix): assert resolved_deps[req] == [ResolvedDependency("flask", Version("2.0.1"))] +def test_resolvelib_sdist_vexing_parse(monkeypatch): + # Some sdist filenames have ambiguous parses: `cffi-1.0.2-2.tar.gz` + # could be parsed as `(cffi, 1.0.2.post2)` or `(cffi-1-0-2, 2)`. + # `packaging.utils.parse_sdist_filename` parses it as the latter, which results + # in a wrong version for `cffi`. + # When this happens, we filter by distribution to ensure we don't select + # an incorrect version number. + data = ( + '' + "cffi-1.0.2-2.tar.gz
" + ) + + monkeypatch.setattr( + pypi_provider.Candidate, "_get_metadata_for_wheel", lambda _: get_metadata_mock() + ) + + resolver = resolvelib.ResolveLibResolver() + monkeypatch.setattr( + resolver.provider.session, "get", lambda _url, **kwargs: get_package_mock(data) + ) + + req = Requirement("cffi") + with pytest.raises(ResolutionImpossible): + dict(resolver.resolve_all(iter([req]))) + + def test_resolvelib_wheel_python_version(monkeypatch): # Some versions stipulate a particular Python version and should be skipped by the provider. # Since `pip-audit` doesn't support Python 2.7, the Flask version below should always be skipped @@ -194,7 +222,7 @@ def test_resolvelib_wheel_python_version(monkeypatch): def test_resolvelib_wheel_canonical_name_mismatch(monkeypatch): - # Call the underlying wheel, Mask instead of Flask. This should throw an `InconsistentCandidate` + # Call the underlying wheel, Mask instead of Flask. This should throw an `ResolutionImpossible` # error. data = ( '