Skip to content

Commit

Permalink
Ensure distutils configuration values do not escape virtual environme…
Browse files Browse the repository at this point in the history
…nt (#1657)

* Ensure distutils configuration values do not escape virtual environment

Distutils has some configuration files where the user may alter paths to
point outside of the virtual environment. Defend against this by
installing a pth file that resets this to their expected path.

Signed-off-by: Bernat Gabor <[email protected]>

* fix CI failure due to #pypa/pip/issues/7778

Signed-off-by: Bernat Gabor <[email protected]>
  • Loading branch information
gaborbernat authored Feb 24, 2020
1 parent ef711b7 commit 9201422
Show file tree
Hide file tree
Showing 11 changed files with 156 additions and 4 deletions.
5 changes: 4 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@

setup(
use_scm_version={"write_to": "src/virtualenv/version.py", "write_to_template": '__version__ = "{version}"'},
setup_requires=["setuptools_scm >= 2"],
setup_requires=[
# this cannot be enabled until https://github.com/pypa/pip/issues/7778 is addressed
# "setuptools_scm >= 2"
],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""
Distutils allows user to configure some arguments via a configuration file:
https://docs.python.org/3/install/index.html#distutils-configuration-files
Some of this arguments though don't make sense in context of the virtual environment files, let's fix them up.
"""
import os
import sys

VIRTUALENV_PATCH_FILE = os.path.join(__file__)


def patch(dist_of):
# we cannot allow the prefix override as that would get packages installed outside of the virtual environment
old_parse_config_files = dist_of.Distribution.parse_config_files

def parse_config_files(self, *args, **kwargs):
result = old_parse_config_files(self, *args, **kwargs)
install_dict = self.get_option_dict("install")

if "prefix" in install_dict: # the prefix governs where to install the libraries
install_dict["prefix"] = VIRTUALENV_PATCH_FILE, os.path.abspath(sys.prefix)

if "install_scripts" in install_dict: # the install_scripts governs where to generate console scripts
script_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "__SCRIPT_DIR__"))
install_dict["install_scripts"] = VIRTUALENV_PATCH_FILE, script_path

return result

dist_of.Distribution.parse_config_files = parse_config_files


def run():
# patch distutils
from distutils import dist

patch(dist)

# patch setuptools (that has it's own copy of the dist package)
try:
from setuptools import dist
except ImportError:
pass # if setuptools is not around that's alright, just don't patch
else:
patch(dist)


run()
22 changes: 22 additions & 0 deletions src/virtualenv/create/via_global_ref/api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from __future__ import absolute_import, unicode_literals

import logging
import os
from abc import ABCMeta

from six import add_metaclass

from virtualenv.util.path import Path
from virtualenv.util.zipapp import ensure_file_on_disk

from ..creator import Creator


Expand Down Expand Up @@ -43,6 +48,23 @@ def add_parser_arguments(cls, parser, interpreter, meta):
help="try to use copies rather than symlinks, even when symlinks are the default for the platform",
)

def create(self):
self.patch_distutils_via_pth()

def patch_distutils_via_pth(self):
"""Patch the distutils package to not be derailed by its configuration files"""
patch_file = Path(__file__).parent / "_distutils_patch_virtualenv.py"
with ensure_file_on_disk(patch_file) as resolved_path:
text = resolved_path.read_text()
text = text.replace('"__SCRIPT_DIR__"', repr(os.path.relpath(str(self.script_dir), str(self.purelib))))
patch_path = self.purelib / "_distutils_patch_virtualenv.py"
logging.debug("add distutils patch file %s", patch_path)
patch_path.write_text(text)

pth = self.purelib / "_distutils_patch_virtualenv.pth"
logging.debug("add distutils patch file %s", pth)
pth.write_text("import _distutils_patch_virtualenv")

def _args(self):
return super(ViaGlobalRefApi, self)._args() + [("global", self.enable_system_site_package)]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def create(self):
finally:
if true_system_site != self.enable_system_site_package:
self.enable_system_site_package = true_system_site
super(ViaGlobalRefVirtualenvBuiltin, self).create()

def ensure_directories(self):
return {self.dest, self.bin_dir, self.script_dir, self.stdlib} | set(self.libs)
Expand Down
5 changes: 4 additions & 1 deletion src/virtualenv/create/via_global_ref/venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ def create(self):
self.create_inline()
else:
self.create_via_sub_process()
# TODO: cleanup activation scripts

# TODO: cleanup activation scripts

for lib in self.libs:
ensure_dir(lib)
super(Venv, self).create()

def create_inline(self):
from venv import EnvBuilder
Expand Down
6 changes: 6 additions & 0 deletions tests/unit/create/console_app/demo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def run():
print("magic")


if __name__ == "__main__":
run()
6 changes: 6 additions & 0 deletions tests/unit/create/console_app/demo/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def run():
print("magic")


if __name__ == "__main__":
run()
15 changes: 15 additions & 0 deletions tests/unit/create/console_app/setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[metadata]
name = demo
version = 1.0.0
description = magic package

[options]
packages = find:
install_requires =

[options.entry_points]
console_scripts =
magic=demo.__main__:run

[bdist_wheel]
universal = true
3 changes: 3 additions & 0 deletions tests/unit/create/console_app/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from setuptools import setup

setup()
43 changes: 42 additions & 1 deletion tests/unit/create/test_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import gc
import logging
import os
import shutil
import stat
import subprocess
import sys
from itertools import product
from textwrap import dedent
from threading import Thread

import pytest
Expand Down Expand Up @@ -131,7 +133,10 @@ def test_create_no_seed(python, creator, isolated, system, coverage_env, special
# pypy cleans up file descriptors periodically so our (many) subprocess calls impact file descriptor limits
# force a cleanup of these on system where the limit is low-ish (e.g. MacOS 256)
gc.collect()
content = list(result.creator.purelib.iterdir())
purelib = result.creator.purelib
patch_files = {purelib / "{}.{}".format("_distutils_patch_virtualenv", i) for i in ("py", "pyc", "pth")}
patch_files.add(purelib / "__pycache__")
content = set(result.creator.purelib.iterdir()) - patch_files
assert not content, "\n".join(ensure_text(str(i)) for i in content)
assert result.creator.env_name == ensure_text(dest.name)
debug = result.creator.debug
Expand Down Expand Up @@ -345,3 +350,39 @@ def test_create_long_path(current_fastest, tmp_path):
cmd = [str(folder)]
result = cli_run(cmd)
subprocess.check_call([str(result.creator.script("pip")), "--version"])


@pytest.mark.parametrize("creator", set(PythonInfo.current_system().creators().key_to_class) - {"builtin"})
def test_create_distutils_cfg(creator, tmp_path, monkeypatch):
cmd = [
ensure_text(str(tmp_path)),
"--activators",
"",
"--creator",
creator,
]
result = cli_run(cmd)

app = Path(__file__).parent / "console_app"
dest = tmp_path / "console_app"
shutil.copytree(str(app), str(dest))

setup_cfg = dest / "setup.cfg"
conf = dedent(
"""
[install]
prefix={}/a
install_scripts={}/b
"""
).format(tmp_path, tmp_path)
setup_cfg.write_text(setup_cfg.read_text() + conf)

monkeypatch.chdir(dest) # distutils will read the setup.cfg from the cwd, so change to that
install_demo_cmd = [str(result.creator.script("pip")), "install", str(dest), "--no-use-pep517"]
subprocess.check_call(install_demo_cmd)

magic = result.creator.script("magic") # console scripts are created in the right location
assert magic.exists()

package_folder = result.creator.platlib / "demo" # prefix is set to the virtualenv prefix for install
assert package_folder.exists()
5 changes: 4 additions & 1 deletion tests/unit/seed/test_boostrap_link_via_app_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ def test_base_bootstrap_link_via_app_data(tmp_path, coverage_env, current_fastes
assert not process.returncode
# pip is greedy here, removing all packages removes the site-package too
if site_package.exists():
post_run = list(site_package.iterdir())
purelib = result.creator.purelib
patch_files = {purelib / "{}.{}".format("_distutils_patch_virtualenv", i) for i in ("py", "pyc", "pth")}
patch_files.add(purelib / "__pycache__")
post_run = set(site_package.iterdir()) - patch_files
assert not post_run, "\n".join(str(i) for i in post_run)

if sys.version_info[0:2] == (3, 4) and os.environ.get(str("PIP_REQ_TRACKER")):
Expand Down

0 comments on commit 9201422

Please sign in to comment.