Skip to content

Commit e856376

Browse files
authored
Harden __main__.py against non-hermetic stdlib. (#3117)
Fixes #3116
1 parent 064005f commit e856376

File tree

10 files changed

+177
-30
lines changed

10 files changed

+177
-30
lines changed

CHANGES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Release Notes
22

3+
## 2.91.2
4+
5+
This release fixes hermeticity of the Pex boot code against the Python stdlib itself. In some corner
6+
cases stdlib modules could be loaded from the PYTHONPATH and lead to failure of the Pex boot code
7+
before reaching its `sys.path` scrubbing code.
8+
9+
* Harden `__main__.py` against non-hermetic stdlib. (#3117)
10+
311
## 2.91.1
412

513
This release partially fixes an interpreter caching bug for CPython interpreters that have the same

pex/bin/pex.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,8 @@ def configure_clp_pex_options(parser):
344344
default=True,
345345
help=(
346346
"If --venv is specified, don't rewrite Python script shebangs in the venv to pass "
347-
"`-sE` to the interpreter; for example, to enable running the venv PEX itself or its "
348-
"Python scripts with a custom `PYTHONPATH`."
347+
"`-I` (or `-sE` for older Pythons) to the interpreter; for example, to enable running "
348+
"the venv PEX itself or its Python scripts with a custom `PYTHONPATH`."
349349
),
350350
)
351351

pex/pex.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -569,8 +569,8 @@ def path(self):
569569
"""Return the path this PEX was built at."""
570570
return self._pex
571571

572-
def execute(self):
573-
# type: () -> Any
572+
def execute(self, python_args=()):
573+
# type: (Sequence[str]) -> Any
574574
"""Execute the PEX.
575575
576576
This function makes assumptions that it is the last function called by the interpreter.
@@ -607,14 +607,14 @@ def execute(self):
607607
V=3,
608608
)
609609

610-
result = self._wrap_coverage(self._wrap_profiling, self._execute)
610+
result = self._wrap_coverage(self._wrap_profiling, self._execute, python_args)
611611
if "PYTHONINSPECT" not in os.environ:
612612
sys.exit(0 if isinstance(result, Globals) else result)
613613
else:
614614
return result
615615

616-
def _execute(self):
617-
# type: () -> Any
616+
def _execute(self, python_args):
617+
# type: (Sequence[str]) -> Any
618618
force_interpreter = self._vars.PEX_INTERPRETER
619619

620620
self._clean_environment(strip_pex_env=self._pex_info.strip_pex_env)
@@ -629,7 +629,7 @@ def _execute(self):
629629

630630
if force_interpreter:
631631
TRACER.log("PEX_INTERPRETER specified, dropping into interpreter")
632-
return self.execute_interpreter()
632+
return self.execute_interpreter(python_args)
633633

634634
if not any(
635635
(
@@ -640,7 +640,7 @@ def _execute(self):
640640
)
641641
):
642642
TRACER.log("No entry point specified, dropping into interpreter")
643-
return self.execute_interpreter()
643+
return self.execute_interpreter(python_args)
644644

645645
if self._pex_info_overrides.script and self._pex_info_overrides.entry_point:
646646
return "Cannot specify both script and entry_point for a PEX!"
@@ -663,32 +663,35 @@ def _execute(self):
663663
assert self._pex_info.entry_point
664664
return self.execute_entry(parse_entry_point(self._pex_info.entry_point))
665665

666-
def execute_interpreter(self):
667-
# type: () -> Any
666+
def execute_interpreter(self, python_args):
667+
# type: (Sequence[str]) -> Any
668668

669669
# A Python interpreter always inserts the CWD at the head of the sys.path.
670670
# See https://docs.python.org/3/library/sys.html#sys.path
671671
sys.path.insert(0, "")
672672

673673
args = sys.argv[1:]
674-
python_options = []
674+
python_options = list(python_args)
675+
called_with_python_options = False
675676
for index, arg in enumerate(args):
676677
# Check if the arg is an expected startup arg.
677678
if arg.startswith("-") and arg not in ("-", "-c", "-m"):
678-
python_options.append(arg)
679+
# N.B.: In the face of short options that can be combined, this is a naive check.
680+
if arg not in python_options:
681+
python_options.append(arg)
682+
called_with_python_options = True
679683
else:
680684
args = args[index:]
681685
break
682686
else:
683687
# All the args were python options
684688
args = []
685689

686-
# The pex was called with Python interpreter options
687-
if python_options:
688-
return self.execute_with_options(python_options, args)
690+
if called_with_python_options:
691+
return self.re_execute_with_options(python_options, args)
689692

690693
if args:
691-
# NB: We take care here to setup sys.argv to match how CPython does it for each case.
694+
# NB: We take care here to set up sys.argv to match how CPython does it for each case.
692695
arg = args[0]
693696
if arg == "-c":
694697
content = args[1]
@@ -734,7 +737,7 @@ def execute_interpreter(self):
734737
return Globals(pex_repl())
735738

736739
@staticmethod
737-
def execute_with_options(
740+
def re_execute_with_options(
738741
python_options, # type: List[str]
739742
args, # List[str]
740743
):
@@ -749,7 +752,7 @@ def execute_with_options(
749752
return "Unable to resolve PEX __main__ module file: {}".format(main)
750753

751754
python = sys.executable
752-
cmdline = [python] + python_options + [main.__file__] + args
755+
cmdline = [python] + python_options + [os.path.dirname(main.__file__)] + args
753756
TRACER.log(
754757
"Re-executing with Python interpreter options: cmdline={cmdline!r}".format(
755758
cmdline=" ".join(cmdline)

pex/pex_boot.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
if sys.version_info >= (3, 10):
1414

1515
def orig_argv():
16-
# type: () -> List[str]
16+
# type: () -> Optional[List[str]]
1717
return sys.orig_argv
1818

1919
else:
@@ -24,7 +24,7 @@ def orig_argv():
2424
from ctypes import pythonapi
2525

2626
def orig_argv():
27-
# type: () -> List[str]
27+
# type: () -> Optional[List[str]]
2828

2929
# Under MyPy for Python 3.5, ctypes.POINTER is incorrectly typed. This code is tested
3030
# to work correctly in practice on all Pythons Pex supports.
@@ -42,8 +42,8 @@ def orig_argv():
4242
except ImportError:
4343
# N.B.: This handles the older PyPy case.
4444
def orig_argv():
45-
# type: () -> List[str]
46-
return []
45+
# type: () -> Optional[List[str]]
46+
return None
4747

4848

4949
def __re_exec__(
@@ -147,6 +147,7 @@ def boot(
147147
bootstrap_dir, # type: str
148148
pex_root, # type: str
149149
pex_hash, # type: str
150+
hermetic_boot, # type: bool
150151
has_interpreter_constraints, # type: bool
151152
pex_path, # type: Tuple[str, ...]
152153
is_venv, # type: bool
@@ -175,11 +176,49 @@ def boot(
175176

176177
python_args = list(inject_python_args) # type: List[str]
177178
orig_args = orig_argv()
178-
if orig_args:
179+
if orig_args is not None:
180+
orig_python_args = [] # type: List[str]
179181
for index, arg in enumerate(orig_args[1:], start=1):
180182
if os.path.exists(arg) and os.path.samefile(entry_point, arg):
181-
python_args.extend(orig_args[1:index])
183+
orig_python_args = orig_args[1:index]
182184
break
185+
python_args.extend(orig_python_args)
186+
187+
# N.B.: This re-exec is shown to cost ~3% of runtime (~30ms) for small, quick zipapp PEXes
188+
# on a cold boot; so we avoid the re-exec unless PYTHONPATH is set, which should be the
189+
# only common way to pollute the stdlib in a manner that doesn't permanently break the
190+
# underlying Python environment in the first place.
191+
if (
192+
"PYTHONPATH" in os.environ
193+
and __SHOULD_EXECUTE__
194+
and hermetic_boot
195+
and os.environ.get("PEX_INHERIT_PATH", "false") == "false"
196+
):
197+
re_exec = False
198+
if sys.version_info[:2] >= (3, 4):
199+
if "-I" not in orig_python_args:
200+
python_args.append("-I")
201+
re_exec = True
202+
else:
203+
has_hermetic_args = (
204+
("-s" in orig_python_args and "-E" in orig_python_args)
205+
or "-sE" in orig_python_args
206+
or "-Es" in orig_python_args
207+
)
208+
if not has_hermetic_args:
209+
python_args.append("-sE")
210+
re_exec = True
211+
if re_exec:
212+
args = [sys.executable]
213+
args.extend(python_args)
214+
args.append(entry_point)
215+
args.extend(sys.argv[1:])
216+
if os.name == "nt":
217+
import subprocess
218+
219+
sys.exit(subprocess.call(args=args))
220+
else:
221+
os.execv(args[0], args)
183222

184223
installed_from = os.environ.pop(__INSTALLED_FROM__, None)
185224
if installed_from:

pex/pex_bootstrapper.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ def bootstrap_pex(
700700
from . import pex
701701

702702
try:
703-
return pex.PEX(entry_point).execute()
703+
return pex.PEX(entry_point).execute(python_args=python_args)
704704
except pex.PEX.Error as e:
705705
return e
706706

pex/pex_builder.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from pex.executables import chmod_plus_x, create_sh_python_redirector_shebang
3434
from pex.finders import get_entry_point_from_console_script, get_script_from_distributions
3535
from pex.fs import safe_rename, safe_symlink
36+
from pex.inherit_path import InheritPath
3637
from pex.installed_wheel import InstalledWheel
3738
from pex.interpreter import PythonInterpreter
3839
from pex.layout import Layout
@@ -483,12 +484,18 @@ def _prepare_code(self):
483484
with open(os.path.join(_ABS_PEX_PACKAGE_DIR, "pex_boot.py")) as fp:
484485
pex_boot = fp.read()
485486

487+
is_venv = self._pex_info.venv
488+
hermetic_boot = (is_venv and self._pex_info.venv_hermetic_scripts) or (
489+
not is_venv and self._pex_info.inherit_path is InheritPath.FALSE
490+
)
491+
486492
pex_main = dedent(
487493
"""
488494
result, should_exit, is_globals = boot(
489495
bootstrap_dir={bootstrap_dir!r},
490496
pex_root={pex_root!r},
491497
pex_hash={pex_hash!r},
498+
hermetic_boot={hermetic_boot!r},
492499
has_interpreter_constraints={has_interpreter_constraints!r},
493500
pex_path={pex_path!r},
494501
is_venv={is_venv!r},
@@ -503,9 +510,10 @@ def _prepare_code(self):
503510
bootstrap_dir=self._pex_info.bootstrap,
504511
pex_root=self._pex_info.raw_pex_root,
505512
pex_hash=self._pex_info.pex_hash,
513+
hermetic_boot=hermetic_boot,
506514
has_interpreter_constraints=bool(self._pex_info.interpreter_constraints),
507515
pex_path=self._pex_info.pex_path,
508-
is_venv=self._pex_info.venv,
516+
is_venv=is_venv,
509517
inject_python_args=self._pex_info.inject_python_args,
510518
)
511519
bootstrap = pex_boot + "\n" + pex_main

pex/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright 2015 Pex project contributors.
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

4-
__version__ = "2.91.1"
4+
__version__ = "2.91.2"

tests/integration/scie/test_pex_scie.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -951,7 +951,9 @@ def assert_output(
951951
in output_lines
952952
)
953953
stderr_import_logging = any(line.startswith("import ") for line in output_lines)
954-
assert expect_python_verbose is stderr_import_logging, "\n".join(output_lines)
954+
assert expect_python_verbose is stderr_import_logging, "\n".join(
955+
output_lines[:5] + ["..."] + output_lines[-5:]
956+
)
955957

956958
assert_output(
957959
args=[busybox, "foo-script-ad-hoc"],
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright 2026 Pex project contributors.
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
4+
from __future__ import absolute_import
5+
6+
import os.path
7+
import subprocess
8+
import sys
9+
10+
import pytest
11+
12+
from pex.common import safe_rmtree
13+
from pex.interpreter import PythonInterpreter
14+
from pex.layout import Layout
15+
from testing import IS_PYPY, make_env, run_pex_command
16+
from testing.cli import run_pex3
17+
from testing.pytest_utils.tmp import Tempdir
18+
19+
20+
@pytest.mark.parametrize(
21+
"layout", [pytest.param(layout, id=layout.value) for layout in Layout.values()]
22+
)
23+
def test_enum_backport_injection_foiled(
24+
tmpdir, # type: Tempdir
25+
current_interpreter, # type: PythonInterpreter
26+
layout, # type: Layout.Value
27+
):
28+
# type: (...) -> None
29+
30+
if IS_PYPY and layout is Layout.ZIPAPP:
31+
pytest.skip(
32+
reason=(
33+
"PyPy imports site when loading a zipapp __main__.py and blows up before we can do "
34+
"anything."
35+
)
36+
)
37+
38+
pythonpath = tmpdir.join("pythonpath")
39+
run_pex3("venv", "create", "--layout", "flat", "-d", pythonpath, "enum34").assert_success()
40+
41+
pex_root = tmpdir.join("pex-root")
42+
pex = tmpdir.join("cowsay.pex")
43+
pex_main = pex if layout is Layout.ZIPAPP else os.path.join(pex, "pex")
44+
45+
run_pex_command(
46+
args=[
47+
"--runtime-pex-root",
48+
pex_root,
49+
"cowsay<6",
50+
"-c",
51+
"cowsay",
52+
"-o",
53+
pex,
54+
"--layout",
55+
layout.value,
56+
]
57+
).assert_success()
58+
59+
assert b"| Moo! |" in subprocess.check_output(args=[pex_main, "Moo!"])
60+
61+
safe_rmtree(pex_root)
62+
assert b"| Boo! |" in subprocess.check_output(args=[sys.executable, pex_main, "Boo!"])
63+
64+
interpreter = sys.executable
65+
extra_env = {}
66+
if 0 != subprocess.call(args=[sys.executable, "-c", ""], env=make_env(PYTHONPATH=pythonpath)):
67+
# N.B.: Injecting `enum34` on the `sys.path` is so insidious that it can foil Python startup
68+
# on its own. In particular, when the python interpreter sits in a venv with modern pep-660
69+
# editables (the Pex test infra case for newer Pythons), those editables `.pth` will fail
70+
# to load early on interpreter startup. Pex is not in the backtrace there, and we can't help
71+
# users in that case - Python itself is poisoned. To avoid that case in tests though, we
72+
# resolve out of our venv interpreter to get the underlying system interpreter with no such
73+
# editable installs / `.pth` files.
74+
interpreter = current_interpreter.resolve_base_interpreter().binary
75+
extra_env = dict(
76+
PATH=os.pathsep.join((os.path.dirname(interpreter), os.environ.get("PATH", os.defpath)))
77+
)
78+
79+
safe_rmtree(pex_root)
80+
assert b"| Foo! |" in subprocess.check_output(
81+
args=[pex_main, "Foo!"], env=make_env(PYTHONPATH=pythonpath, **extra_env)
82+
)
83+
84+
safe_rmtree(pex_root)
85+
assert b"| Bar! |" in subprocess.check_output(
86+
args=[interpreter, pex_main, "Bar!"], env=make_env(PYTHONPATH=pythonpath)
87+
)

tests/test_pex.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,7 +752,7 @@ def test_execute_interpreter_dashm_module_with_python_options():
752752
)
753753
stdout, stderr = process.communicate()
754754

755-
assert b"" == stderr
755+
assert b"" == stderr, stderr
756756
assert "{} one two\n".format(
757757
os.path.realpath(os.path.join(pex_chroot, "foo/bar.py"))
758758
) == stdout.decode("utf-8")

0 commit comments

Comments
 (0)