1import os
2import sys
3import tempfile
4import operator
5import functools
6import itertools
7import re
8import contextlib
9import pickle
10import textwrap
11
12from setuptools.extern import six
13from setuptools.extern.six.moves import builtins, map
14
15import pkg_resources
16from distutils.errors import DistutilsError
17from pkg_resources import working_set
18
19if sys.platform.startswith('java'):
20    import org.python.modules.posix.PosixModule as _os
21else:
22    _os = sys.modules[os.name]
23try:
24    _file = file
25except NameError:
26    _file = None
27_open = open
28
29
30__all__ = [
31    "AbstractSandbox", "DirectorySandbox", "SandboxViolation", "run_setup",
32]
33
34
35def _execfile(filename, globals, locals=None):
36    """
37    Python 3 implementation of execfile.
38    """
39    mode = 'rb'
40    with open(filename, mode) as stream:
41        script = stream.read()
42    if locals is None:
43        locals = globals
44    code = compile(script, filename, 'exec')
45    exec(code, globals, locals)
46
47
48@contextlib.contextmanager
49def save_argv(repl=None):
50    saved = sys.argv[:]
51    if repl is not None:
52        sys.argv[:] = repl
53    try:
54        yield saved
55    finally:
56        sys.argv[:] = saved
57
58
59@contextlib.contextmanager
60def save_path():
61    saved = sys.path[:]
62    try:
63        yield saved
64    finally:
65        sys.path[:] = saved
66
67
68@contextlib.contextmanager
69def override_temp(replacement):
70    """
71    Monkey-patch tempfile.tempdir with replacement, ensuring it exists
72    """
73    os.makedirs(replacement, exist_ok=True)
74
75    saved = tempfile.tempdir
76
77    tempfile.tempdir = replacement
78
79    try:
80        yield
81    finally:
82        tempfile.tempdir = saved
83
84
85@contextlib.contextmanager
86def pushd(target):
87    saved = os.getcwd()
88    os.chdir(target)
89    try:
90        yield saved
91    finally:
92        os.chdir(saved)
93
94
95class UnpickleableException(Exception):
96    """
97    An exception representing another Exception that could not be pickled.
98    """
99
100    @staticmethod
101    def dump(type, exc):
102        """
103        Always return a dumped (pickled) type and exc. If exc can't be pickled,
104        wrap it in UnpickleableException first.
105        """
106        try:
107            return pickle.dumps(type), pickle.dumps(exc)
108        except Exception:
109            # get UnpickleableException inside the sandbox
110            from setuptools.sandbox import UnpickleableException as cls
111            return cls.dump(cls, cls(repr(exc)))
112
113
114class ExceptionSaver:
115    """
116    A Context Manager that will save an exception, serialized, and restore it
117    later.
118    """
119
120    def __enter__(self):
121        return self
122
123    def __exit__(self, type, exc, tb):
124        if not exc:
125            return
126
127        # dump the exception
128        self._saved = UnpickleableException.dump(type, exc)
129        self._tb = tb
130
131        # suppress the exception
132        return True
133
134    def resume(self):
135        "restore and re-raise any exception"
136
137        if '_saved' not in vars(self):
138            return
139
140        type, exc = map(pickle.loads, self._saved)
141        six.reraise(type, exc, self._tb)
142
143
144@contextlib.contextmanager
145def save_modules():
146    """
147    Context in which imported modules are saved.
148
149    Translates exceptions internal to the context into the equivalent exception
150    outside the context.
151    """
152    saved = sys.modules.copy()
153    with ExceptionSaver() as saved_exc:
154        yield saved
155
156    sys.modules.update(saved)
157    # remove any modules imported since
158    del_modules = (
159        mod_name for mod_name in sys.modules
160        if mod_name not in saved
161        # exclude any encodings modules. See #285
162        and not mod_name.startswith('encodings.')
163    )
164    _clear_modules(del_modules)
165
166    saved_exc.resume()
167
168
169def _clear_modules(module_names):
170    for mod_name in list(module_names):
171        del sys.modules[mod_name]
172
173
174@contextlib.contextmanager
175def save_pkg_resources_state():
176    saved = pkg_resources.__getstate__()
177    try:
178        yield saved
179    finally:
180        pkg_resources.__setstate__(saved)
181
182
183@contextlib.contextmanager
184def setup_context(setup_dir):
185    temp_dir = os.path.join(setup_dir, 'temp')
186    with save_pkg_resources_state():
187        with save_modules():
188            with save_path():
189                hide_setuptools()
190                with save_argv():
191                    with override_temp(temp_dir):
192                        with pushd(setup_dir):
193                            # ensure setuptools commands are available
194                            __import__('setuptools')
195                            yield
196
197
198_MODULES_TO_HIDE = {
199    'setuptools',
200    'distutils',
201    'pkg_resources',
202    'Cython',
203    '_distutils_hack',
204}
205
206
207def _needs_hiding(mod_name):
208    """
209    >>> _needs_hiding('setuptools')
210    True
211    >>> _needs_hiding('pkg_resources')
212    True
213    >>> _needs_hiding('setuptools_plugin')
214    False
215    >>> _needs_hiding('setuptools.__init__')
216    True
217    >>> _needs_hiding('distutils')
218    True
219    >>> _needs_hiding('os')
220    False
221    >>> _needs_hiding('Cython')
222    True
223    """
224    base_module = mod_name.split('.', 1)[0]
225    return base_module in _MODULES_TO_HIDE
226
227
228def hide_setuptools():
229    """
230    Remove references to setuptools' modules from sys.modules to allow the
231    invocation to import the most appropriate setuptools. This technique is
232    necessary to avoid issues such as #315 where setuptools upgrading itself
233    would fail to find a function declared in the metadata.
234    """
235    _distutils_hack = sys.modules.get('_distutils_hack', None)
236    if _distutils_hack is not None:
237        _distutils_hack.remove_shim()
238
239    modules = filter(_needs_hiding, sys.modules)
240    _clear_modules(modules)
241
242
243def run_setup(setup_script, args):
244    """Run a distutils setup script, sandboxed in its directory"""
245    setup_dir = os.path.abspath(os.path.dirname(setup_script))
246    with setup_context(setup_dir):
247        try:
248            sys.argv[:] = [setup_script] + list(args)
249            sys.path.insert(0, setup_dir)
250            # reset to include setup dir, w/clean callback list
251            working_set.__init__()
252            working_set.callbacks.append(lambda dist: dist.activate())
253
254            # __file__ should be a byte string on Python 2 (#712)
255            dunder_file = (
256                setup_script
257                if isinstance(setup_script, str) else
258                setup_script.encode(sys.getfilesystemencoding())
259            )
260
261            with DirectorySandbox(setup_dir):
262                ns = dict(__file__=dunder_file, __name__='__main__')
263                _execfile(setup_script, ns)
264        except SystemExit as v:
265            if v.args and v.args[0]:
266                raise
267            # Normal exit, just return
268
269
270class AbstractSandbox:
271    """Wrap 'os' module and 'open()' builtin for virtualizing setup scripts"""
272
273    _active = False
274
275    def __init__(self):
276        self._attrs = [
277            name for name in dir(_os)
278            if not name.startswith('_') and hasattr(self, name)
279        ]
280
281    def _copy(self, source):
282        for name in self._attrs:
283            setattr(os, name, getattr(source, name))
284
285    def __enter__(self):
286        self._copy(self)
287        if _file:
288            builtins.file = self._file
289        builtins.open = self._open
290        self._active = True
291
292    def __exit__(self, exc_type, exc_value, traceback):
293        self._active = False
294        if _file:
295            builtins.file = _file
296        builtins.open = _open
297        self._copy(_os)
298
299    def run(self, func):
300        """Run 'func' under os sandboxing"""
301        with self:
302            return func()
303
304    def _mk_dual_path_wrapper(name):
305        original = getattr(_os, name)
306
307        def wrap(self, src, dst, *args, **kw):
308            if self._active:
309                src, dst = self._remap_pair(name, src, dst, *args, **kw)
310            return original(src, dst, *args, **kw)
311
312        return wrap
313
314    for name in ["rename", "link", "symlink"]:
315        if hasattr(_os, name):
316            locals()[name] = _mk_dual_path_wrapper(name)
317
318    def _mk_single_path_wrapper(name, original=None):
319        original = original or getattr(_os, name)
320
321        def wrap(self, path, *args, **kw):
322            if self._active:
323                path = self._remap_input(name, path, *args, **kw)
324            return original(path, *args, **kw)
325
326        return wrap
327
328    if _file:
329        _file = _mk_single_path_wrapper('file', _file)
330    _open = _mk_single_path_wrapper('open', _open)
331    for name in [
332        "stat", "listdir", "chdir", "open", "chmod", "chown", "mkdir",
333        "remove", "unlink", "rmdir", "utime", "lchown", "chroot", "lstat",
334        "startfile", "mkfifo", "mknod", "pathconf", "access"
335    ]:
336        if hasattr(_os, name):
337            locals()[name] = _mk_single_path_wrapper(name)
338
339    def _mk_single_with_return(name):
340        original = getattr(_os, name)
341
342        def wrap(self, path, *args, **kw):
343            if self._active:
344                path = self._remap_input(name, path, *args, **kw)
345                return self._remap_output(name, original(path, *args, **kw))
346            return original(path, *args, **kw)
347
348        return wrap
349
350    for name in ['readlink', 'tempnam']:
351        if hasattr(_os, name):
352            locals()[name] = _mk_single_with_return(name)
353
354    def _mk_query(name):
355        original = getattr(_os, name)
356
357        def wrap(self, *args, **kw):
358            retval = original(*args, **kw)
359            if self._active:
360                return self._remap_output(name, retval)
361            return retval
362
363        return wrap
364
365    for name in ['getcwd', 'tmpnam']:
366        if hasattr(_os, name):
367            locals()[name] = _mk_query(name)
368
369    def _validate_path(self, path):
370        """Called to remap or validate any path, whether input or output"""
371        return path
372
373    def _remap_input(self, operation, path, *args, **kw):
374        """Called for path inputs"""
375        return self._validate_path(path)
376
377    def _remap_output(self, operation, path):
378        """Called for path outputs"""
379        return self._validate_path(path)
380
381    def _remap_pair(self, operation, src, dst, *args, **kw):
382        """Called for path pairs like rename, link, and symlink operations"""
383        return (
384            self._remap_input(operation + '-from', src, *args, **kw),
385            self._remap_input(operation + '-to', dst, *args, **kw)
386        )
387
388
389if hasattr(os, 'devnull'):
390    _EXCEPTIONS = [os.devnull]
391else:
392    _EXCEPTIONS = []
393
394
395class DirectorySandbox(AbstractSandbox):
396    """Restrict operations to a single subdirectory - pseudo-chroot"""
397
398    write_ops = dict.fromkeys([
399        "open", "chmod", "chown", "mkdir", "remove", "unlink", "rmdir",
400        "utime", "lchown", "chroot", "mkfifo", "mknod", "tempnam",
401    ])
402
403    _exception_patterns = [
404        # Allow lib2to3 to attempt to save a pickled grammar object (#121)
405        r'.*lib2to3.*\.pickle$',
406    ]
407    "exempt writing to paths that match the pattern"
408
409    def __init__(self, sandbox, exceptions=_EXCEPTIONS):
410        self._sandbox = os.path.normcase(os.path.realpath(sandbox))
411        self._prefix = os.path.join(self._sandbox, '')
412        self._exceptions = [
413            os.path.normcase(os.path.realpath(path))
414            for path in exceptions
415        ]
416        AbstractSandbox.__init__(self)
417
418    def _violation(self, operation, *args, **kw):
419        from setuptools.sandbox import SandboxViolation
420        raise SandboxViolation(operation, args, kw)
421
422    if _file:
423
424        def _file(self, path, mode='r', *args, **kw):
425            if mode not in ('r', 'rt', 'rb', 'rU', 'U') and not self._ok(path):
426                self._violation("file", path, mode, *args, **kw)
427            return _file(path, mode, *args, **kw)
428
429    def _open(self, path, mode='r', *args, **kw):
430        if mode not in ('r', 'rt', 'rb', 'rU', 'U') and not self._ok(path):
431            self._violation("open", path, mode, *args, **kw)
432        return _open(path, mode, *args, **kw)
433
434    def tmpnam(self):
435        self._violation("tmpnam")
436
437    def _ok(self, path):
438        active = self._active
439        try:
440            self._active = False
441            realpath = os.path.normcase(os.path.realpath(path))
442            return (
443                self._exempted(realpath)
444                or realpath == self._sandbox
445                or realpath.startswith(self._prefix)
446            )
447        finally:
448            self._active = active
449
450    def _exempted(self, filepath):
451        start_matches = (
452            filepath.startswith(exception)
453            for exception in self._exceptions
454        )
455        pattern_matches = (
456            re.match(pattern, filepath)
457            for pattern in self._exception_patterns
458        )
459        candidates = itertools.chain(start_matches, pattern_matches)
460        return any(candidates)
461
462    def _remap_input(self, operation, path, *args, **kw):
463        """Called for path inputs"""
464        if operation in self.write_ops and not self._ok(path):
465            self._violation(operation, os.path.realpath(path), *args, **kw)
466        return path
467
468    def _remap_pair(self, operation, src, dst, *args, **kw):
469        """Called for path pairs like rename, link, and symlink operations"""
470        if not self._ok(src) or not self._ok(dst):
471            self._violation(operation, src, dst, *args, **kw)
472        return (src, dst)
473
474    def open(self, file, flags, mode=0o777, *args, **kw):
475        """Called for low-level os.open()"""
476        if flags & WRITE_FLAGS and not self._ok(file):
477            self._violation("os.open", file, flags, mode, *args, **kw)
478        return _os.open(file, flags, mode, *args, **kw)
479
480
481WRITE_FLAGS = functools.reduce(
482    operator.or_, [
483        getattr(_os, a, 0) for a in
484        "O_WRONLY O_RDWR O_APPEND O_CREAT O_TRUNC O_TEMPORARY".split()]
485)
486
487
488class SandboxViolation(DistutilsError):
489    """A setup script attempted to modify the filesystem outside the sandbox"""
490
491    tmpl = textwrap.dedent("""
492        SandboxViolation: {cmd}{args!r} {kwargs}
493
494        The package setup script has attempted to modify files on your system
495        that are not within the EasyInstall build area, and has been aborted.
496
497        This package cannot be safely installed by EasyInstall, and may not
498        support alternate installation locations even if you run its setup
499        script by hand.  Please inform the package's author and the EasyInstall
500        maintainers to find out if a fix or workaround is available.
501        """).lstrip()
502
503    def __str__(self):
504        cmd, args, kwargs = self.args
505        return self.tmpl.format(**locals())
506