Python: 受限制的 "函数调用"

需求背景

最近在工做上, 遇到了一个比较特殊的需求:python

为了安全, 设计一个函数或者装饰器, 而后用户在 "定义/调用" 函数时, 只能访问到咱们容许的内置变量和全局变量

经过例子来这解释下上面的需求:segmentfault

a = 123
def func():
    print  a
    print id(a)

func()   

# 输出
123
32081168

函数功能简单明了, 对于结果, 你们应该也不会有太大的异议:func分别是取得全局命名空间a的值和使用内置命名空间中的函数id获取了a的地址. 熟悉Python的童鞋, 对于LEGB确定也是不陌生的,也正是由于LEGB才让函数func输出正确的结果. 可是这个只是一个常规例子, 只是用来抛砖引玉而已. 咱们真正想要讨论的是下面的例子:安全

# 装饰函数
def wrap(f):
    # 调用用户传入的函数
    f()

a = 123

# 用户自定义函数
def func():
    import os
    print os.listdir('.')

wrap(func)
# 输出
['1.yml', '2.py', '2.txt', '2.yml', 'ftp', 'ftp.rar', 'test', 'tmp', '__init__.py']

潜在危险因素

在上面的例子能够看出, 若是在func中, 引入别的模块, 而后再执行模块中的方法, 也是可行的! 并且这仍是一个很是方便的功能! 可是除了方便, 更多的是一种潜在的危险.在平常使用, 或许咱们不会考虑这些, 可是若是在模块模块之间的协同做用时, 特别是多人参与的状况下, 这种危险的因素, 就不得不让咱们认真对待!多线程

或许有不少同窗会以为这些担心是过多的, 是不必的, 可是请思考一种场景: 咱们有个主模块, 暂时称为main.py, 它容许用户动态加载模块, 也就是说只要用户将对应的模块放到对应的目录, 而后利用消息机制去通知main.py, 告诉它应该加载新模块了, 而且执行新模块里面的b函数, 那在这种状况下, main.py确定不能直接傻傻的就去执行, 由于咱们不能相信每一个用户都是诚实善良的, 也不能相信每一个用户编写的模块或者函数是符合咱们的行为标准规范. 因此咱们得有些措施去防范这些事情, 咱们能作的大概也就下面几种方式:框架

1.在用户通知`main.py`时有新模块加入而且要求执行函数时, 先对模块的代码作检查, 不符合标准或者带有危险代码的拒绝加载.
2.控制好`内置命名空间`和`全局命名空间`, 使其只能用容许使用的内容

在方案1, 其实也是咱们最容易想到的方法, 可是这个方法的成本仍是比较高, 由于咱们须要将可能出现的错误代码或者关键词,所有写成一套规则, 并且这套规则还很大可能会误伤, 不过也可能业界已经有相似的成熟的方案, 只是我还没接触到而已.
因此咱们只能用方案2的方法, 这种方法在咱们看来, 是成本比较低的, 也比较容易控制的, 由于这就和防火墙同样, 咱们只放行咱们容许的事物.函数

具体实现

实现方案2最大的问题就是, 如何控制内置命名空间全局命名空间
咱们第一个想法确定就是覆盖它们, 由于咱们都知道不论是内置命名空间仍是全局命名空间, 都是经过字典的形式在维护:学习

print globals()
print globals()['__builtins__'].__dict__

# 输出
# 全局命名空间
{'__builtins__': <module '__builtin__' (built-in)>, '__name__': '__main__', '__file__': 'D:/Python_project/ftp/2.py', '__doc__': None, '__package__': None}

#内置命名空间
{'bytearray': <type 'bytearray'>, 'IndexError': <type 'excep.....(省略过多部分)..}

注: globals函数 是用来打印当前全局命名空间的函数, 一样, 也能经过修改这个函数返回的字典对应的key, 实现全局命名空间的修改.例如:测试

s = globals()
print s
s['a'] = 3
print s
print a

# 输出
{'__builtins__': <module '__builtin__' (built-in)>, '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 's': {...}, '__name__': '__main__', '__doc__': None}
{'a': 3, '__builtins__': <module '__builtin__' (built-in)>, '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 's': {...}, '__name__': '__main__', '__doc__': None}
3

能够看出, 咱们并无定义变量a, 只是在globals的返回值上面增长了key-value, 就变相实现了咱们定义的操做, 这其实也能用于不少但愿可以动态赋值的需求场景! 好比说, 我不肯定有多少个变量, 但愿经过一个变量名列表, 动态生成这些变量, 在这种状况下, 就能参考这种方法, 不过仍是但愿谨慎使用, 由于修改了这个, 就是就修改了全局命名空间.ui

好了, 回归到本文, 咱们已经知道经过globals函数可以表明全局命名空间, 可是为何内置命名空间要用globals()['__builtins__'].__dict__来表示? 其实这个和python自身的机制有关, 由于模块在编译和初始化的过程当中, 内置命名空间就是以这种形式,寄放在全局命名空间:this

static void
initmain(void)
{
    PyObject *m, *d;
    m = PyImport_AddModule("__main__");
    if (m == NULL)
        Py_FatalError("can't create __main__ module");
    d = PyModule_GetDict(m);
    if (PyDict_GetItemString(d, "__builtins__") == NULL) {
        PyObject *bimod = PyImport_ImportModule("__builtin__");
        if (bimod == NULL ||
            PyDict_SetItemString(d, "__builtins__", bimod) != 0)
            Py_FatalError("can't add __builtins__ to __main__");
        Py_XDECREF(bimod);
    }
}

从上面代码能够看出, 在初始化__main__时, 会有一个获取__builtins__的动做, 若是这个结果是NULL, 那么就会用以前初始化好的__builtin__去存进去, 这些代码具体能够看Pythonrun.c, 在这不详细展开了.

既然内置命名空间(__builtins__)全局命名空间(globals())都已经找到对应对象了, 那咱们下一步就应该是想法将这两个空间替换成咱们想要的.

# coding: utf8
# 修改全局命名空间
test_var = 123  # 测试变量

tmp = globals().keys()
print globals()
print test_var
for i in tmp:
    del globals()[i]
print globals()
print test_var
print id(2)

# 输出

{'tmp': ['__builtins__', '__file__', '__package__', 'test_var', '__name__', '__doc__'], '__builtins__': <module '__builtin__' (built-in)>, '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 'test_var': 123, '__name__': '__main__', '__doc__': None}
123
{'tmp': ['__builtins__', '__file__', '__package__', 'test_var', '__name__', '__doc__'], 'i': '__doc__'}
Traceback (most recent call last):
  File "D:/Python_project/ftp/2.py", line 10, in <module>
    print test_var
NameError: name 'test_var' is not defined

在上面的输出能够看到, 在删除先后, 经过print globals()能够看到全局命名空间确实已经被修改了, 由于test_var已经没法打印了, 触发了NameError, 这样的话, 就有办法可以限制全局命令空间了:

# 伪代码

# 装饰函数
def wrap(f):
    # 调用用户传入的函数
    .... 修改全局命名空间
    f()
    .... 还原全局命名空间

a = 123

# 用户自定义函数
def func():
    import os
    print os.listdir('.')

wrap(func)

为何我只写伪代码, 由于我发现这个功能实现起来是很是蛋疼! 缘由就是, 在实现以前, 咱们必需要解决几个问题:

1.全局命名空间对应了一个字典, 因此若是咱们想要修改, 只能从修改这个字典自己, 因而先清空再定义成咱们约束的, 调用完以后, 又得反过来恢复, 这些操做是十分之蛋疼.
2.涉及到共享的问题, 若是这个用户函数处理好久, 并且是多线程的, 那么整个模块都会变得很不稳定, 甚至称为"污染"

那就先撇开不讲, 讲讲内置命名空间, 刚才咱们已经找到了能表明内置命名空间的对象, 很幸运的是, 这个是"真的可以摸获得"的, 那咱们试下直接就赋值个空字典, 看会怎样:

s = globals()
print s['__builtins__']  # __builtins__检查是否存在
s['__builtins__'] = {}
print s['__builtins__']  # __builtins__检查是否存在
print id(3)              # 试下内置函数可否使用
print globals()

# 输出
<module '__builtin__' (built-in)>
{}
32602360
{'__builtins__': {}, '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 's': {...}, '__name__': '__main__', '__doc__': None}

结果有点尴尬, 彷佛没啥用, 可是其实这个__builtins__只是一个表现, 真正的内置命名空间是在它所指向的字典对象, 也就是: globals()['__builtins__'].__dict__!

print globals()['__builtins__'].__dict__

# 输出
{'bytearray': <type 'bytearray'>, 'IndexError': <type 'exceptions.IndexError'>....} # 省略

因此咱们真正要覆盖的, 是这个字典才对, 因此上面的代码要改为:

s = globals()
s['__builtins__'].__dict__ = {}   # 覆盖真正的内置命名空间
print s['__builtins__'].__dict__  # __builtins__检查是否存在

# 输出
Traceback (most recent call last):
  File "D:/Python_project/ftp/2.py", line 3, in <module>
    s['__builtins__'].__dict__ = {}
TypeError: readonly attribute

失败了...原来这个内置命名空间是只读的, 因此咱们上面的方法都失败了..那难道真的无法解决了吗? 通常这样问, 一般都有解决方案滴~

完美方案

这个解决方法, 须要一个库的帮忙~, 那就是inspect库, 这个库是干吗呢? 简单来讲就是用来自省. 它提供四种用处:

1.对是不是模块,框架,函数等进行类型检查。
2.获取源码
3.获取类或函数的参数的信息
4.解析堆栈

在这里, 咱们须要用到第二个功能, 其他的功能, 感兴趣的童鞋能够去谷歌学习哦, 也能够参考: https://my.oschina.net/taisha...
除了inspect, 咱们还须要用到exec, 这也是一大杀器, 能够先参考这个学习下: http://www.mojidong.com/pytho...

方法大体的过程就是如下几步:

1.根据用户传入的func对象, 利用inspect取出对应的源码
2.经过exec利用源码而且传入全局命名空间, 从新编译

代码:

# coding: utf8
import inspect

# 装饰函数
def wrap(f):
    # 调用用户传入的函数
    source = inspect.getsource(f)   # 获取源码
    exec('%s \n%s()' % (source,  f.func_name), {'a': 'this is inspect', '__builtins__': {}})  # 从新编译, 而且从新构造全局命名空间


a = 123

# 用户自定义函数
def func():
    print a
    import os
    print os.listdir('.')

wrap(func)

# 输出
this is inspect
Traceback (most recent call last):
  File "D:/Python_project/ftp/2.py", line 19, in <module>
    wrap(func)
  File "D:/Python_project/ftp/2.py", line 8, in wrap
    exec('%s \nfunc()' % source, {'a': 'this is inspect', '__builtins__': {}})
  File "<string>", line 6, in <module>
  File "<string>", line 3, in func
ImportError: __import__ not found

虽然上面报错了, 但那不就咱们梦寐以求结果吗? 咱们能够正确的输出a的值this is inspe, 并且当funcimport时, 直接报错! 这样就能知足咱们的变态欲望了~ 嘿嘿!,

关于代码运行原理, 其实在关键部位的代码, 都已经加了注释, 可能在exec那部分会比较迷惑, 但其实你们将对应的变量代入字符串就能懂了, 替换以后, 其实也就是函数的定义+执行, 能够经过print '%s \n%s()' % (source, f.func_name)帮助理解.然后面的字典, 也就是咱们一直很纠结的全局命名空间, 其中内置命名空间也被人为定义了, 因此可以达到咱们想要的效果了!

这种只是一种抛砖引玉, 让有相似场景需求的童鞋, 有个参考的方向, 也欢迎分享大家实现的方案, 嘿嘿!

欢迎各位大神指点交流,转载请注明来源: https://segmentfault.com/a/11...

相关文章
相关标签/搜索