You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

537 line
15 KiB

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