From 3a31a7d886fa7018ecf71530dd7484f1830db4c7 Mon Sep 17 00:00:00 2001 From: Alex Willmer Date: Mon, 4 Mar 2024 16:28:17 +0000 Subject: [PATCH] mitogen: Workaround CPython importlib PermissionError when cwd is unreadable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS when using a become plugin as an unprivileged user, to another unprivileged user it is likely that the current working directory can't be read. In this case os.cwd() raises PermissionError. On versions of Python currently in the wild (March 2024, CPython <= 3.13) if any non-builtin or non-frozen module (e.g. zlib, base64) is imported then `importlib._bootstrap_external.PathFinder._path_importer_cache()` attempts to call os.cwd() without catching PermissionError. The previous comment about needing an extra .encode() appears to be wrong, atleast for Python 3.x >= 3.6. Command size increased by 54 bytes, bootstrap by 804 bytes. Changed from codecs module to binascii & zlib because they're extensions, and importing them triggers fewer supporting imports (e.g. encodings module). Before ``` ✗ ./preamble_size.py SSH command size: 705 Bootstrap (mitogen.core) size: 17078 (16.68KiB) Original Minimized Compressed mitogen.parent 97884 95.6KiB 50515 49.3KiB 51.6% 12727 12.4KiB 13.0% mitogen.fork 8436 8.2KiB 4130 4.0KiB 49.0% 1648 1.6KiB 19.5% mitogen.ssh 10892 10.6KiB 6952 6.8KiB 63.8% 2113 2.1KiB 19.4% mitogen.sudo 12089 11.8KiB 5924 5.8KiB 49.0% 2249 2.2KiB 18.6% mitogen.select 12325 12.0KiB 2929 2.9KiB 23.8% 964 0.9KiB 7.8% mitogen.service 41699 40.7KiB 22477 22.0KiB 53.9% 5885 5.7KiB 14.1% mitogen.fakessh 15577 15.2KiB 7989 7.8KiB 51.3% 2623 2.6KiB 16.8% mitogen.master 51398 50.2KiB 25715 25.1KiB 50.0% 6886 6.7KiB 13.4% ``` After ``` ✗ ./preamble_size.py SSH command size: 759 Bootstrap (mitogen.core) size: 17882 (17.46KiB) Original Minimized Compressed mitogen.parent 98173 95.9KiB 50571 49.4KiB 51.5% 12747 12.4KiB 13.0% mitogen.fork 8436 8.2KiB 4130 4.0KiB 49.0% 1648 1.6KiB 19.5% mitogen.ssh 10892 10.6KiB 6952 6.8KiB 63.8% 2113 2.1KiB 19.4% mitogen.sudo 12089 11.8KiB 5924 5.8KiB 49.0% 2249 2.2KiB 18.6% mitogen.select 12325 12.0KiB 2929 2.9KiB 23.8% 964 0.9KiB 7.8% mitogen.service 41699 40.7KiB 22477 22.0KiB 53.9% 5885 5.7KiB 14.1% mitogen.fakessh 15577 15.2KiB 7989 7.8KiB 51.3% 2623 2.6KiB 16.8% mitogen.master 56116 54.8KiB 29427 28.7KiB 52.4% 7627 7.4KiB 13.6% ``` Fixes #885 Refs https://github.com/python/cpython/issues/115911 --- docs/changelog.rst | 2 ++ mitogen/core.py | 29 ++++++++++++++++++++++++++++- mitogen/parent.py | 21 +++++++++++---------- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ef26a047..32829ef6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,6 +21,8 @@ Unreleased ---------- * :gh:issue:`987` Support Python 3.11 +* :gh:issue:`885` Fix :py:exc:`PermissionError` in :py:mod:`importlib` when + becoming an unprivileged user with Python 3.x v0.3.4 (2023-07-02) diff --git a/mitogen/core.py b/mitogen/core.py index 707e901a..6a3f3da7 100644 --- a/mitogen/core.py +++ b/mitogen/core.py @@ -34,6 +34,34 @@ non-essential code in order to reduce its size, since it is also serves as the bootstrap implementation sent to every new slave context. """ +import sys +try: + import _frozen_importlib_external +except ImportError: + pass +else: + class MonkeyPatchedPathFinder(_frozen_importlib_external.PathFinder): + """ + Meta path finder for sys.path and package __path__ attributes. + + Patched for https://github.com/python/cpython/issues/115911. + """ + @classmethod + def _path_importer_cache(cls, path): + if path == '': + try: + path = _frozen_importlib_external._os.getcwd() + except (FileNotFoundError, PermissionError): + return None + return super()._path_importer_cache(path) + + if sys.version_info[:2] <= (3, 12): + for i, mpf in enumerate(sys.meta_path): + if mpf is _frozen_importlib_external.PathFinder: + sys.meta_path[i] = MonkeyPatchedPathFinder + del i, mpf + + import binascii import collections import encodings.latin_1 @@ -49,7 +77,6 @@ import pstats import signal import socket import struct -import sys import syslog import threading import time diff --git a/mitogen/parent.py b/mitogen/parent.py index 1045ddc3..29bcf66d 100644 --- a/mitogen/parent.py +++ b/mitogen/parent.py @@ -34,7 +34,7 @@ sent to any child context that is due to become a parent, due to recursive connection. """ -import codecs +import binascii import errno import fcntl import getpass @@ -1405,6 +1405,7 @@ class Connection(object): # file descriptor 0 as 100, creates a pipe, then execs a new interpreter # with a custom argv. # * Optimized for minimum byte count after minification & compression. + # The script preamble_size.py measures this. # * 'CONTEXT_NAME' and 'PREAMBLE_COMPRESSED_LEN' are substituted with # their respective values. # * CONTEXT_NAME must be prefixed with the name of the Python binary in @@ -1449,7 +1450,7 @@ class Connection(object): os.environ['ARGV0']=sys.executable os.execl(sys.executable,sys.executable+'(mitogen:CONTEXT_NAME)') os.write(1,'MITO000\n'.encode()) - C=_(os.fdopen(0,'rb').read(PREAMBLE_COMPRESSED_LEN),'zip') + C=zlib.decompress(os.fdopen(0,'rb').read(PREAMBLE_COMPRESSED_LEN)) fp=os.fdopen(W,'wb',0) fp.write(C) fp.close() @@ -1481,16 +1482,16 @@ class Connection(object): source = source.replace('PREAMBLE_COMPRESSED_LEN', str(len(preamble_compressed))) compressed = zlib.compress(source.encode(), 9) - encoded = codecs.encode(compressed, 'base64').replace(b('\n'), b('')) - # We can't use bytes.decode() in 3.x since it was restricted to always - # return unicode, so codecs.decode() is used instead. In 3.x - # codecs.decode() requires a bytes object. Since we must be compatible - # with 2.4 (no bytes literal), an extra .encode() either returns the - # same str (2.x) or an equivalent bytes (3.x). + encoded = binascii.b2a_base64(compressed).replace(b('\n'), b('')) + + # Just enough to decode, decompress, and exec the first stage. + # Priorities: wider compatibility, faster startup, shorter length. + # `import os` here, instead of stage 1, to save a few bytes. + # `sys.path=...` for https://github.com/python/cpython/issues/115911. return self.get_python_argv() + [ '-c', - 'import codecs,os,sys;_=codecs.decode;' - 'exec(_(_("%s".encode(),"base64"),"zip"))' % (encoded.decode(),) + 'import sys;sys.path=[p for p in sys.path if p];import binascii,os,zlib;' + 'exec(zlib.decompress(binascii.a2b_base64("%s")))' % (encoded.decode(),), ] def get_econtext_config(self):