Repository: trailofbits/pip-audit
Pull request #344 (2022-07-29 19:42:53)

resolvelib, test: handle venv creation errors gracefully
This is a _partial_ solution for #340 -- it converts an uncontrolled exception into one we control, which a later PR can then use to add some appropriate end-user messaging (such as a custom message telling the user that their system Python is badly borked).

Some follow-up options (non-exhaustive) are listed in https://github.com/trailofbits/pip-audit/issues/340#issuecomment-1199800600.

See #340.

Signed-off-by: William Woodruff <william@trailofbits.com>

woodruffw: @tetsuo-cpp mind taking a look at this when you have a moment?

Patch:
diff --git a/pip_audit/_dependency_source/resolvelib/pypi_provider.py b/pip_audit/_dependency_source/resolvelib/pypi_provider.py
index 1cae3ac..8753625 100644
--- a/pip_audit/_dependency_source/resolvelib/pypi_provider.py
+++ b/pip_audit/_dependency_source/resolvelib/pypi_provider.py
@@ -11,6 +11,7 @@ from email.parser import BytesParser
 from io import BytesIO
 from operator import attrgetter
 from pathlib import Path
+from subprocess import CalledProcessError
 from tempfile import TemporaryDirectory
 from typing import Any, BinaryIO, Iterator, List, Mapping, Optional, Set, Union, cast
 from urllib.parse import urlparse
@@ -29,7 +30,7 @@ from resolvelib.resolvers import RequirementInformation
 from pip_audit._cache import caching_session
 from pip_audit._state import AuditState
 from pip_audit._util import python_version
-from pip_audit._virtual_env import VirtualEnv
+from pip_audit._virtual_env import VirtualEnv, VirtualEnvError
 
 # TODO: Final[Version] when our minimal Python is 3.8.
 PYTHON_VERSION: Version = python_version()
@@ -159,8 +160,18 @@ class Candidate:
             )
 
             with TemporaryDirectory() as ve_dir:
-                ve = VirtualEnv([str(sdist)], self._state)
-                ve.create(ve_dir)
+                try:
+                    ve = VirtualEnv([str(sdist)], self._state)
+                    ve.create(ve_dir)
+                except CalledProcessError as e:
+                    # NOTE: Virtual environment creation failure with this error likely indicates
+                    # some kind of layering violation, such as an incorrectly configured
+                    # system Python interfering with a virtual environment's `python` or `pip`.
+                    # We don't really have a cleaner place to capture and transform it,
+                    # since it happens directly in the core `venv` standard library module.
+                    # See: https://bugs.python.org/issue38705
+                    # See: https://github.com/pypa/build/issues/294
+                    raise VirtualEnvError("virtual environment creation failed internally") from e
 
                 self._state.update_state(
                     f"Querying installed packages for {self.name} ({self.version})"


Test patch:
diff --git a/test/dependency_source/resolvelib/__init__.py b/test/dependency_source/resolvelib/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/dependency_source/resolvelib/test_pypi_provider.py b/test/dependency_source/resolvelib/test_pypi_provider.py
new file mode 100644
index 0000000..85ed7a1
--- /dev/null
+++ b/test/dependency_source/resolvelib/test_pypi_provider.py
@@ -0,0 +1,52 @@
+from pathlib import Path
+from subprocess import CalledProcessError
+
+import pretend
+import pytest
+from packaging.version import Version
+
+from pip_audit._dependency_source.resolvelib import pypi_provider
+from pip_audit._dependency_source.resolvelib.pypi_provider import Candidate
+from pip_audit._virtual_env import VirtualEnvError
+
+
+class TestCandidate:
+    def test_get_metadata_for_sdist_venv_create_fails(self, monkeypatch):
+        virtualenv_obj = pretend.stub(
+            create=pretend.call_recorder(
+                pretend.raiser(CalledProcessError(returncode=1, cmd=["fake"]))
+            )
+        )
+        virtualenv_cls = pretend.call_recorder(lambda args, state: virtualenv_obj)
+        monkeypatch.setattr(pypi_provider, "VirtualEnv", virtualenv_cls)
+
+        response = pretend.stub(
+            raise_for_status=pretend.call_recorder(lambda: None), content=b"fake sdist content"
+        )
+        session = pretend.stub(get=pretend.call_recorder(lambda url, timeout: response))
+
+        state = pretend.stub(update_state=pretend.call_recorder(lambda s: None))
+
+        candidate = Candidate(
+            "fakepkg",
+            Path("fakepath"),
+            Version("1.0.0"),
+            url="hxxps://fake.url",
+            extras=set(),
+            is_wheel=False,
+            session=session,
+            timeout=None,
+            state=state,
+        )
+
+        with pytest.raises(
+            VirtualEnvError, match=r"virtual environment creation failed internally"
+        ):
+            candidate._get_metadata_for_sdist()
+
+        assert len(virtualenv_obj.create.calls) == 1
+        assert state.update_state.calls == [
+            pretend.call(
+                "Installing source distribution in isolated environment for fakepkg (1.0.0)"
+            ),
+        ]