Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Guard against fake interpreters. #1500

Merged
merged 1 commit into from
Oct 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions pex/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,6 @@ def create_interpreter(


encoded_identity = PythonIdentity.get(binary={binary!r}).encode()
sys.stdout.write(encoded_identity)
with atomic_directory({cache_dir!r}, exclusive=False) as cache_dir:
if not cache_dir.is_finalized:
with safe_open(
Expand All @@ -749,7 +748,7 @@ def create_interpreter(
cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd
)
job = Job(command=cmd, process=process, finalizer=lambda: safe_rmtree(cwd))
return SpawnedJob.stdout(job, result_func=create_interpreter)
return SpawnedJob.file(job, output_file=cache_file, result_func=create_interpreter)

@classmethod
def _expand_path(cls, path):
Expand Down
87 changes: 75 additions & 12 deletions pex/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
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
Expand Down Expand Up @@ -105,6 +104,30 @@ def kill(self):
finally:
self._finalize_job()

def create_error(
self,
msg, # type: str
stderr=None, # type: Optional[bytes]
):
# type: (...) -> Job.Error
"""Creates an error with this Job's details.

:param msg: The message for the error.
:param stderr: Any stderr output captured from the job.
:return: A job error.
"""
err = None
if stderr:
err = stderr.decode("utf-8")
msg += "\nSTDERR:\n{}".format(err)
raise self.Error(
pid=self._process.pid,
command=self._command,
exitcode=self._process.returncode,
stderr=err,
message=msg,
)

def _finalize_job(self):
if self._finalizer is not None:
self._finalizer()
Expand All @@ -113,20 +136,10 @@ def _finalize_job(self):
def _check_returncode(self, stderr=None):
# type: (Optional[bytes]) -> None
if self._process.returncode != 0:
err = None
msg = "Executing {} failed with {}".format(
" ".join(self._command), self._process.returncode
)
if stderr:
err = stderr.decode("utf-8")
msg += "\nSTDERR:\n{}".format(err)
raise self.Error(
pid=self._process.pid,
command=self._command,
exitcode=self._process.returncode,
stderr=err,
message=msg,
)
raise self.create_error(msg, stderr=stderr)

def __str__(self):
# type: () -> str
Expand Down Expand Up @@ -245,6 +258,56 @@ def __repr__(self):

return Stdout()

@classmethod
def file(
cls,
job, # type: Job
output_file, # type: str
result_func, # type: Callable[[bytes], _T]
input=None, # type: Optional[bytes]
):
# type: (...) -> SpawnedJob[_T]
"""Wait for the job to complete and return a result derived from a file the job creates.

:param job: The spawned job.
:param output_file: The path of the file the job will create.
:param result_func: A function taking the byte contents of the file the spawned job
created and returning the desired result.
:param input: Optional input stream data to pass to the process as per the
`subprocess.Popen.communicate` API.
:return: A spawned job whose result is derived from the contents of a file it creates.
"""

def _read_file(stderr=None):
# type: (Optional[bytes]) -> bytes
try:
with open(output_file, "rb") as fp:
return fp.read()
except (OSError, IOError) as e:
raise job.create_error(
"Expected job to create file {output_file!r} but it did not exist or could not "
"be read: {err}".format(output_file=output_file, err=e),
stderr=stderr,
)

class File(SpawnedJob):
def await_result(self):
# type: () -> _T
_, stderr = job.communicate(input=input)
return result_func(_read_file(stderr=stderr))

def kill(self):
# type: () -> None
job.kill()

def __repr__(self):
# type: () -> str
return "SpawnedJob.file({job!r}, output_file={output_file!r})".format(
job=job, output_file=output_file
)

return File()

def await_result(self):
# type: () -> _T
"""Waits for the spawned job to complete and returns its result."""
Expand Down
69 changes: 68 additions & 1 deletion tests/test_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@
import glob
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from contextlib import contextmanager
from textwrap import dedent

import pytest

from pex import interpreter
from pex.common import safe_mkdir, safe_mkdtemp, temporary_dir, touch
from pex.common import chmod_plus_x, safe_mkdir, safe_mkdtemp, temporary_dir, touch
from pex.compatibility import PY3
from pex.executor import Executor
from pex.interpreter import PythonInterpreter
from pex.jobs import Job
from pex.pyenv import Pyenv
from pex.testing import (
PY27,
Expand Down Expand Up @@ -417,3 +421,66 @@ def test_identify_cwd_isolation_issues_1231(tmpdir):
assert 1 == len(interp_info_files)
with open(interp_info_files.pop()) as fp:
assert interp.binary == json.load(fp)["binary"]


@pytest.fixture(scope="module")
def macos_monterey_interpeter(tmpdir_factory):
# type: (Any) -> str
pythonwrapper = os.path.join(str(tmpdir_factory.mktemp("bin")), "pythonwrapper")
with open(pythonwrapper, "w") as fp:
fp.write(
dedent(
"""\
#!/usr/bin/env bash
echo >&2 "pythonwrapper[3129:20922] \\
pythonwrapper is not supposed to be executed directly. Exiting."
exit 0
"""
)
)
chmod_plus_x(pythonwrapper)

python = os.path.join(str(tmpdir_factory.mktemp("bin")), "python")
os.symlink(pythonwrapper, python)
return python


def test_issue_1494_job_error_not_identification_error(
macos_monterey_interpeter, # type: str
tmpdir, # type: Any
):
# type: (...) -> None
pex_root = os.path.join(str(tmpdir), "pex_root")
with ENV.patch(PEX_ROOT=pex_root):
spawned_job = PythonInterpreter._spawn_from_binary(macos_monterey_interpeter)
with pytest.raises(Job.Error) as exc_info:
spawned_job.await_result()
exc_info.match(
r"^Expected job to create file '{}/interpreters/[0-9a-f/]+/INTERP-INFO' "
r"but it did not exist or could not be read: ".format(re.escape(pex_root))
)
exc_info.match(
r"\n"
r"STDERR:\n"
r"pythonwrapper\[3129:20922\] "
r"pythonwrapper is not supposed to be executed directly. Exiting.\n$"
)


def test_issue_1494_iter(macos_monterey_interpeter):
# type: (str) -> None
assert [PythonInterpreter.get()] == list(
PythonInterpreter.iter(paths=[sys.executable, macos_monterey_interpeter])
)


def test_issue_1494_iter_candidates(macos_monterey_interpeter):
# type: (str) -> None
assert [
PythonInterpreter.get(),
(
macos_monterey_interpeter,
"pythonwrapper[3129:20922] pythonwrapper is not supposed to be executed directly. "
"Exiting.",
),
] == list(PythonInterpreter.iter_candidates(paths=[sys.executable, macos_monterey_interpeter]))