Repository: pex-tool/pex

Issue:
Interpreter identification leaks an unconstrained `$PWD` entry into `sys.path`.
This provides unexpected alternate sources for Pex code as this example demonstrates:
```
$ python3.8 -mvenv venv
$ venv/bin/pip -q install -U pip
$ venv/bin/pip -q install pex
$ venv/bin/pex --version
2.1.30
$ venv/bin/pip -q install --target dir pex==2.1.16
$ ls dir/
bin  pex  pex-2.1.16.dist-info
$ venv/bin/pex isort -c isort -o dir/isort.pex
$ cd dir/
dir $ rm -rf ~/.pex/interpreters/
dir $ ./isort.pex 


                 _                 _
                (_) ___  ___  _ __| |_
                | |/ _/ / _ \/ '__  _/
                | |\__ \/\_\/| |  | |_
                |_|\___/\___/\_/   \_/

      isort your imports, so you don't have to.

                    VERSION 5.7.0


Nothing to do: no files or paths have have been passed in!

Try one of the following:

    `isort .` - sort all Python files, starting from the current directory, recursively.
    `isort . --interactive` - Do the same, but ask before making any changes.
    `isort . --check --diff` - Check to see if imports are correctly sorted within this project.
    `isort --help` - In-depth information about isort's available command-line options.

Visit https://pycqa.github.io/isort/ for complete information about how to use isort.

dir $ rm -rf ~/.pex/interpreters/
dir $ source ../venv/bin/activate
(venv) dir $ ./isort.pex 
Traceback (most recent call last):
  File "/usr/lib/python3.8/runpy.py", line 194, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/usr/lib/python3.8/runpy.py", line 87, in _run_code
    exec(code, run_globals)
  File "./isort.pex/__main__.py", line 81, in <module>
  File "/tmp/pex_ii_leak/dir/isort.pex/.bootstrap/pex/pex_bootstrapper.py", line 403, in bootstrap_pex
  File "/tmp/pex_ii_leak/dir/isort.pex/.bootstrap/pex/pex_bootstrapper.py", line 305, in maybe_reexec_pex
  File "/tmp/pex_ii_leak/dir/isort.pex/.bootstrap/pex/interpreter.py", line 922, in resolve_base_interpreter
  File "/tmp/pex_ii_leak/dir/isort.pex/.bootstrap/pex/interpreter.py", line 710, in from_binary
  File "/tmp/pex_ii_leak/dir/isort.pex/.bootstrap/pex/jobs.py", line 199, in await_result
  File "/tmp/pex_ii_leak/dir/isort.pex/.bootstrap/pex/jobs.py", line 78, in communicate
  File "/tmp/pex_ii_leak/dir/isort.pex/.bootstrap/pex/jobs.py", line 103, in _check_returncode
pex.jobs.Error: Executing /usr/bin/python3.8 -s -c import os
import sys

from pex.common import atomic_directory, safe_open
from pex.interpreter import PythonIdentity


encoded_identity = PythonIdentity.get(binary='/usr/bin/python3.8').encode()
sys.stdout.write(encoded_identity)
with atomic_directory('/home/jsirois/.pex/interpreters/f841ccf354cda73fa22fdf414a02f6c680c1b032/a65b2009934de8713e1f759d0553218a35dd5bfe/e90219f1da0abbc0e93646d91bc21868bc95a74d', exclusive=False) as cache_dir:
    if cache_dir:
        with safe_open(os.path.join(cache_dir, 'INTERP-INFO'), 'w') as fp:
            fp.write(encoded_identity)
 failed with 1
STDERR:
Traceback (most recent call last):
  File "<string>", line 8, in <module>
TypeError: get() got an unexpected keyword argument 'binary'

```


Discussion:

Resolving patch:
diff --git a/pex/interpreter.py b/pex/interpreter.py
--- a/pex/interpreter.py
+++ b/pex/interpreter.py
@@ -16,7 +16,7 @@
 from textwrap import dedent
 
 from pex import third_party
-from pex.common import is_exe, safe_rmtree
+from pex.common import is_exe, safe_mkdtemp, safe_rmtree, temporary_dir
 from pex.compatibility import string
 from pex.executor import Executor
 from pex.jobs import ErrorHandler, Job, Retain, SpawnedJob, execute_parallel
@@ -654,10 +654,13 @@ def create_interpreter(stdout, check_binary=False):
                 ],
                 pythonpath=pythonpath,
             )
+            # Ensure the `.` implicit PYTHONPATH entry contains no Pex code (of a different version)
+            # that might interfere with the behavior we expect in the script above.
+            cwd = safe_mkdtemp()
             process = Executor.open_process(
-                cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE
+                cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd
             )
-            job = Job(command=cmd, process=process)
+            job = Job(command=cmd, process=process, finalizer=lambda: safe_rmtree(cwd))
             return SpawnedJob.stdout(job, result_func=create_interpreter)
 
     @classmethod
diff --git a/pex/jobs.py b/pex/jobs.py
--- a/pex/jobs.py
+++ b/pex/jobs.py
@@ -8,6 +8,7 @@
 from abc import abstractmethod
 from threading import BoundedSemaphore, Event, Thread
 
+from pex.attrs import str_tuple_from_iterable
 from pex.compatibility import AbstractClass, Queue, cpu_count
 from pex.tracer import TRACER
 from pex.typing import TYPE_CHECKING, Generic
@@ -49,14 +50,18 @@ def __init__(
         self,
         command,  # type: Iterable[str]
         process,  # type: subprocess.Popen
+        finalizer=None,  # type: Optional[Callable[[], None]]
     ):
         # type: (...) -> None
         """
         :param command: The command used to spawn the job process.
         :param process: The spawned process handle.
+        :param finalizer: An optional cleanup function to call exactly once when the underlying
+                          process terminates in the course of calling this job's public methods.
         """
         self._command = tuple(command)
         self._process = process
+        self._finalizer = finalizer
 
     def wait(self):
         # type: () -> None
@@ -64,8 +69,11 @@ def wait(self):
 
         :raises: :class:`Job.Error` if the job exited non-zero.
         """
-        self._process.wait()
-        self._check_returncode()
+        try:
+            self._process.wait()
+            self._check_returncode()
+        finally:
+            self._finalize_job()
 
     def communicate(self, input=None):
         # type: (Optional[bytes]) -> Tuple[bytes, bytes]
@@ -76,9 +84,12 @@ def communicate(self, input=None):
         :return: A tuple of the job's stdout and stderr as per the `subprocess` API.
         :raises: :class:`Job.Error` if the job exited non-zero.
         """
-        stdout, stderr = self._process.communicate(input=input)
-        self._check_returncode(stderr)
-        return stdout, stderr
+        try:
+            stdout, stderr = self._process.communicate(input=input)
+            self._check_returncode(stderr)
+            return stdout, stderr
+        finally:
+            self._finalize_job()
 
     def kill(self):
         # type: () -> None
@@ -91,6 +102,13 @@ def kill(self):
         except OSError as e:
             if e.errno != errno.ESRCH:
                 raise e
+        finally:
+            self._finalize_job()
+
+    def _finalize_job(self):
+        if self._finalizer is not None:
+            self._finalizer()
+            self._finalizer = None
 
     def _check_returncode(self, stderr=None):
         # type: (Optional[bytes]) -> None


Test patch:
diff --git a/pex/testing.py b/pex/testing.py
--- a/pex/testing.py
+++ b/pex/testing.py
@@ -528,3 +528,14 @@ def adjust_environment(mapping):
         yield
     finally:
         adjust_environment(existing)
+
+
+@contextmanager
+def pushd(directory):
+    # type: (str) -> Iterator[None]
+    cwd = os.getcwd()
+    try:
+        os.chdir(directory)
+        yield
+    finally:
+        os.chdir(cwd)
diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py
--- a/tests/test_interpreter.py
+++ b/tests/test_interpreter.py
@@ -2,6 +2,7 @@
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
 
 import glob
+import json
 import os
 import subprocess
 from collections import defaultdict
@@ -20,7 +21,9 @@
     PY_VER,
     ensure_python_distribution,
     ensure_python_interpreter,
+    ensure_python_venv,
     environment_as,
+    pushd,
 )
 from pex.typing import TYPE_CHECKING
 from pex.variables import ENV
@@ -332,3 +335,25 @@ def test_resolve_venv_ambient():
     # type: () -> None
     ambient_real_interpreter = PythonInterpreter.get().resolve_base_interpreter()
     check_resolve_venv(ambient_real_interpreter)
+
+
+def test_identify_cwd_isolation_issues_1231(tmpdir):
+    # type: (Any) -> None
+
+    python36, pip = ensure_python_venv(PY36)
+    polluted_cwd = os.path.join(str(tmpdir), "dir")
+    subprocess.check_call(args=[pip, "install", "--target", polluted_cwd, "pex==2.1.16"])
+
+    pex_root = os.path.join(str(tmpdir), "pex_root")
+    with pushd(polluted_cwd), ENV.patch(PEX_ROOT=pex_root):
+        interp = PythonInterpreter.from_binary(python36)
+
+    interp_info_files = {
+        os.path.join(root, f)
+        for root, _, files in os.walk(pex_root)
+        for f in files
+        if f == PythonInterpreter.INTERP_INFO_FILE
+    }
+    assert 1 == len(interp_info_files)
+    with open(interp_info_files.pop()) as fp:
+        assert interp.binary == json.load(fp)["binary"]