cloudmatrix/esky

View on GitHub
esky/sudo/__init__.py

Summary

Maintainability
D
2 days
Test Coverage
#  Copyright (c) 2009-2010, Cloud Matrix Pty. Ltd.
#  All rights reserved; available under the terms of the BSD License.
"""

  esky.sudo:  spawn a root-privileged helper app to process esky updates.

This module provides the infrastructure for spawning a stand-alone "helper app"
to install updates with root privileges.  The class "SudoProxy" provides a
proxy to the methods of an object via a root-privileged helper process.

Example:

    app.install_version("1.2.3")
    -->   IOError:  permission denied

    sapp = SudoProxy(app)
    sapp.start()
    -->   prompts for credentials
    sapp.install_version("1.2.3")
    -->   success!


We also provide some handy utility functions:

    * has_root():      check whether current process has root privileges
    * can_get_root():  check whether current process may be able to get root
    


"""

from __future__ import absolute_import

import sys
import time

from esky.util import lazy_import

@lazy_import
def functools():
    import functools
    return functools

@lazy_import
def pickle():
    try:
       import cPickle as pickle
    except ImportError:
        import pickle
    return pickle

@lazy_import
def threading():
    try:
        import threading
    except ImportError:
        threading = None
    return threading


if sys.platform == "win32":
    @lazy_import
    def _impl():
        from esky.sudo import sudo_win32
        return sudo_win32
elif sys.platform == "darwin":
    @lazy_import
    def _impl():
        try:
            from esky.sudo import sudo_osx
            return sudo_osx
        except ImportError:
            from esky.sudo import sudo_unix
            return sudo_unix
else:
    @lazy_import
    def _impl():
        from esky.sudo import sudo_unix
        return sudo_unix


def spawn_sudo(proxy):
    return _impl.spawn_sudo(proxy)

def has_root():
    return _impl.has_root()

def can_get_root():
    return _impl.can_get_root()

def run_startup_hooks():
    if len(sys.argv) > 1 and sys.argv[1] == "--esky-spawn-sudo":
        return _impl.run_startup_hooks()


def b(data):
    """Like b"data", but valid syntax in older pythons as well.

    Sadly 2to3 can't get string constants right.
    """
    return data.encode("ascii")


class SudoProxy(object):
    """Object method proxy with root privileges.

    This class creates a copy of an object whose methods can be executed
    with root privileges.
    """

    def __init__(self,target):
        #  Reflect the 'name' attribute if it has one, but don't worry
        #  if not.  This helps SudoProxy be re-used on other classes.
        try:
            self.name = target.name
        except AttributeError:
            pass
        self.target = target
        self.closed = False
        self.pipe = None

    def start(self):
        (self.proc,self.pipe) = spawn_sudo(self)
        if self.proc.poll() is not None:
            raise RuntimeError("sudo helper process terminated unexpectedly")
        #  If threading is available, run a background thread to monitor
        #  the sudo process.  If it dies, terminate things immediately.
        if threading:
            self._do_monitor_proc = True
            monitor_thread = threading.Thread(target=self._monitor_proc)
            monitor_thread.daemon = True
            monitor_thread.start()
        #  Try to read initialisation message from the pipe.
        #  If this fails, the helper program must have died.
        try:
            msg = self.pipe.read()
        except EOFError:
            msg = b("")
        if msg != b("READY"):
            self.close()
            raise RuntimeError("failed to spawn helper app")
        if threading:
            self._do_monitor_proc = False
            monitor_thread.join()

    def _monitor_proc(self):
        while self._do_monitor_proc:
            if self.proc.poll() is not None:
                self.pipe._recover()
                self.pipe.close()
                break
            time.sleep(0)

    def close(self):
        self.pipe.write(b("CLOSE"))
        self.pipe.read()
        self.closed = True

    def terminate(self):
        if not self.closed:
            self.close()
        self.pipe.close()
        self.pipe = None
        self.proc.terminate()

    def run(self,pipe):
        self.target.sudo_proxy = None
        pipe.write(b("READY"))
        try:
            #  Process incoming commands in a loop.
            while True:
                try:
                    methname = pipe.read().decode("ascii")
                    if methname == "CLOSE":
                        pipe.write(b("CLOSING"))
                        break
                    else:
                        argtypes = _get_sudo_argtypes(self.target,methname)
                        iterator = _get_sudo_iterator(self.target,methname)
                        if argtypes is None:
                            msg = "attribute '%s' not allowed from sudo"
                            raise AttributeError(msg % (attr,))
                        method = getattr(self.target,methname)
                        args = []
                        for t in argtypes:
                            if t is str:
                                args.append(pipe.read().decode("ascii"))
                            else:
                                args.append(t(pipe.read()))
                        try:
                            res = method(*args)
                        except Exception, e:
                            pipe.write(pickle.dumps((False,e)))
                        else:
                            if not iterator:
                                pipe.write(pickle.dumps((True,res)))
                            else:
                                try:
                                    for item in res:
                                        pipe.write(pickle.dumps((True,item)))
                                except Exception, e:
                                    pipe.write(pickle.dumps((False,e)))
                                else:
                                    SI = StopIteration
                                    pipe.write(pickle.dumps((False,SI)))
                except EOFError:
                    break
            #  Stay alive until the pipe is closed, but don't execute
            #  any further commands.
            while True:
                try:
                    pipe.read()
                except EOFError:
                    break
        finally:
            pipe.close()

    def __getattr__(self,attr):
        if attr.startswith("_"):
            raise AttributeError(attr)
        target = self.__dict__["target"]
        if _get_sudo_argtypes(target,attr) is None:
            msg = "attribute '%s' not allowed from sudo" % (attr,)
            raise AttributeError(msg)
        method = getattr(target,attr)
        pipe = self.__dict__["pipe"]
        if not _get_sudo_iterator(target,attr):
            @functools.wraps(method.im_func)
            def wrapper(*args):
                pipe.write(method.im_func.func_name.encode("ascii"))
                for arg in args:
                    pipe.write(str(arg).encode("ascii"))
                (success,result) = pickle.loads(pipe.read())
                if not success:
                    raise result
                return result
        else:
            @functools.wraps(method.im_func)
            def wrapper(*args):
                pipe.write(method.im_func.func_name.encode("ascii"))
                for arg in args:
                    pipe.write(str(arg).encode("ascii"))
                (success,result) = pickle.loads(pipe.read())
                while success:
                    yield result
                    (success,result) = pickle.loads(pipe.read())
                if result is not StopIteration:
                    raise result
        setattr(self,attr,wrapper)
        return wrapper


def allow_from_sudo(*argtypes,**kwds):
    """Method decorator to allow access to a method via the sudo proxy.

    This decorator wraps an Esky method so that it can be called via the
    esky's sudo proxy.  It is also used to declare type conversions/checks
    on the arguments given to the method.  Example:

        @allow_from_sudo(str)
        def install_version(self,version):
            if self.sudo_proxy is not None:
                return self.sudo_proxy.install_version(version)
            ...

    Note that there are two aspects to transparently tunneling a method call
    through the sudo proxy: allowing it via this decorator, and actually 
    passing on the call to the proxy object.  I have no intention of making
    this any more hidden, because the fact that a method can have escalated
    privileges is something that that needs to be very obvious from the code.
    """
    def decorator(func):
        func._esky_sudo_argtypes = argtypes
        func._esky_sudo_iterator = kwds.pop("iterator",False)
        return func
    return decorator


def _get_sudo_argtypes(obj,methname):
    """Get the argtypes list for the given method.

    This searches the base classes of obj if the given method is not declared
    allowed_from_sudo, so that people don't have to constantly re-apply the
    decorator.
    """
    for base in _get_mro(obj):
        try:
            argtypes = base.__dict__[methname]._esky_sudo_argtypes
        except (KeyError,AttributeError):
            pass
        else:
            return argtypes
    return None

def _get_sudo_iterator(obj,methname):
    """Get the iterator flag for the given method.

    This searches the base classes of obj if the given method is not declared
    allowed_from_sudo, so that people don't have to constantly re-apply the
    decorator.
    """
    for base in _get_mro(obj):
        try:
            iterator = base.__dict__[methname]._esky_sudo_iterator
        except (KeyError,AttributeError):
            pass
        else:
            return iterator
    return False

def _get_mro(obj):
    """Get the method resolution order for an object.

    In other words, get the list of classes what are used to look up methods
    on the given object, in the order in which they'll be consulted.
    """
    try:
        return obj.__class__.__mro__
    except AttributeError:
        return _get_oldstyle_mro(obj.__class__,set())

def _get_oldstyle_mro(cls,seen):
    """Get the method resolution order bor an old-style class.

    This is essentially a bottom-up left-to-right iteration of all the
    superclasses.
    """
    yield cls
    seen.add(cls)
    for base in cls.__bases__:
        if base not in seen:
            for ancestor in _get_oldstyle_mro(base,seen):
                yield ancestor