Mercurial > pydertron
annotate pydertron.py @ 16:9426f9fa6dc0
Added docs.
author | Atul Varma <varmaa@toolness.com> |
---|---|
date | Thu, 10 Sep 2009 14:55:24 -0700 |
parents | 16fe9c63aedb |
children | 9fe3f115f951 |
rev | line source |
---|---|
16 | 1 """ |
2 Pydertron is a high-level wrapper for Pydermonkey that provides convenient, | |
3 secure object wrapping between JS and Python space. | |
4 """ | |
5 | |
0 | 6 import sys |
7 import threading | |
8 import traceback | |
9 import weakref | |
10 import types | |
7
2117265e4dfe
Fixed some threading issues.
Atul Varma <varmaa@toolness.com>
parents:
6
diff
changeset
|
11 import atexit |
0 | 12 |
13 import pydermonkey | |
14 | |
15 class ContextWatchdogThread(threading.Thread): | |
16 """ | |
17 Watches active JS contexts and triggers their operation callbacks | |
18 at a regular interval. | |
19 """ | |
20 | |
21 # Default interval, in seconds, that the operation callbacks are | |
22 # triggered at. | |
8
fb0b161542b1
Improved performance of watchdog thread.
Atul Varma <varmaa@toolness.com>
parents:
7
diff
changeset
|
23 DEFAULT_INTERVAL = 0.25 |
0 | 24 |
25 def __init__(self, interval=DEFAULT_INTERVAL): | |
26 threading.Thread.__init__(self) | |
27 self._lock = threading.Lock() | |
28 self._stop = threading.Event() | |
29 self._contexts = [] | |
30 self.interval = interval | |
31 | |
32 def add_context(self, cx): | |
33 self._lock.acquire() | |
34 try: | |
35 self._contexts.append(weakref.ref(cx)) | |
36 finally: | |
37 self._lock.release() | |
38 | |
39 def join(self): | |
40 self._stop.set() | |
41 threading.Thread.join(self) | |
42 | |
43 def run(self): | |
44 while not self._stop.isSet(): | |
45 new_list = [] | |
46 self._lock.acquire() | |
47 try: | |
48 for weakcx in self._contexts: | |
49 cx = weakcx() | |
50 if cx: | |
51 new_list.append(weakcx) | |
52 cx.trigger_operation_callback() | |
7
2117265e4dfe
Fixed some threading issues.
Atul Varma <varmaa@toolness.com>
parents:
6
diff
changeset
|
53 del cx |
0 | 54 self._contexts = new_list |
55 finally: | |
56 self._lock.release() | |
8
fb0b161542b1
Improved performance of watchdog thread.
Atul Varma <varmaa@toolness.com>
parents:
7
diff
changeset
|
57 self._stop.wait(self.interval) |
0 | 58 |
59 # Create a global watchdog. | |
60 watchdog = ContextWatchdogThread() | |
61 watchdog.start() | |
7
2117265e4dfe
Fixed some threading issues.
Atul Varma <varmaa@toolness.com>
parents:
6
diff
changeset
|
62 atexit.register(watchdog.join) |
0 | 63 |
64 class InternalError(BaseException): | |
65 """ | |
66 Represents an error in a JS-wrapped Python function that wasn't | |
67 expected to happen; because it's derived from BaseException, it | |
68 unrolls the whole JS/Python stack so that the error can be | |
69 reported to the outermost calling code. | |
70 """ | |
71 | |
72 def __init__(self): | |
73 BaseException.__init__(self) | |
74 self.exc_info = sys.exc_info() | |
75 | |
76 class SafeJsObjectWrapper(object): | |
77 """ | |
16 | 78 Securely wraps a JS object to behave like any normal Python |
79 object. Like JS objects, though, accessing undefined object | |
80 results merely in pydermonkey.undefined. | |
81 | |
82 Object properties may be accessed either via attribute or | |
83 item-based lookup. | |
0 | 84 """ |
85 | |
86 __slots__ = ['_jsobject', '_sandbox', '_this'] | |
87 | |
88 def __init__(self, sandbox, jsobject, this): | |
89 if not isinstance(jsobject, pydermonkey.Object): | |
90 raise TypeError("Cannot wrap '%s' object" % | |
91 type(jsobject).__name__) | |
92 object.__setattr__(self, '_sandbox', sandbox) | |
93 object.__setattr__(self, '_jsobject', jsobject) | |
94 object.__setattr__(self, '_this', this) | |
95 | |
96 @property | |
97 def wrapped_jsobject(self): | |
98 return self._jsobject | |
99 | |
100 def _wrap_to_python(self, jsvalue): | |
101 return self._sandbox.wrap_jsobject(jsvalue, self._jsobject) | |
102 | |
103 def _wrap_to_js(self, value): | |
104 return self._sandbox.wrap_pyobject(value) | |
105 | |
106 def __eq__(self, other): | |
107 if isinstance(other, SafeJsObjectWrapper): | |
108 return self._jsobject == other._jsobject | |
109 else: | |
110 return False | |
111 | |
112 def __str__(self): | |
113 return self.toString() | |
114 | |
115 def __unicode__(self): | |
116 return self.toString() | |
117 | |
118 def __setitem__(self, item, value): | |
119 self.__setattr__(item, value) | |
120 | |
121 def __setattr__(self, name, value): | |
122 cx = self._sandbox.cx | |
123 jsobject = self._jsobject | |
124 | |
125 cx.define_property(jsobject, name, | |
126 self._wrap_to_js(value)) | |
127 | |
128 def __getitem__(self, item): | |
129 return self.__getattr__(item) | |
130 | |
131 def __getattr__(self, name): | |
132 cx = self._sandbox.cx | |
133 jsobject = self._jsobject | |
134 | |
135 return self._wrap_to_python(cx.get_property(jsobject, name)) | |
136 | |
137 def __contains__(self, item): | |
138 cx = self._sandbox.cx | |
139 jsobject = self._jsobject | |
140 | |
141 return cx.has_property(jsobject, item) | |
142 | |
143 def __iter__(self): | |
144 cx = self._sandbox.cx | |
145 jsobject = self._jsobject | |
146 | |
147 properties = cx.enumerate(jsobject) | |
148 for property in properties: | |
149 yield property | |
150 | |
151 class SafeJsFunctionWrapper(SafeJsObjectWrapper): | |
152 """ | |
153 Securely wraps a JS function to behave like any normal Python object. | |
154 """ | |
155 | |
156 def __init__(self, sandbox, jsfunction, this): | |
157 if not isinstance(jsfunction, pydermonkey.Function): | |
158 raise TypeError("Cannot wrap '%s' object" % | |
159 type(jsobject).__name__) | |
160 SafeJsObjectWrapper.__init__(self, sandbox, jsfunction, this) | |
161 | |
162 def __call__(self, *args): | |
163 cx = self._sandbox.cx | |
164 jsobject = self._jsobject | |
165 this = self._this | |
166 | |
167 arglist = [] | |
168 for arg in args: | |
169 arglist.append(self._wrap_to_js(arg)) | |
170 | |
171 obj = cx.call_function(this, jsobject, tuple(arglist)) | |
172 return self._wrap_to_python(obj) | |
173 | |
11 | 174 def format_stack(js_stack, open=open): |
0 | 175 """ |
176 Returns a formatted Python-esque stack traceback of the given | |
177 JS stack. | |
178 """ | |
179 | |
180 STACK_LINE =" File \"%(filename)s\", line %(lineno)d, in %(name)s" | |
181 | |
182 lines = [] | |
183 while js_stack: | |
184 script = js_stack['script'] | |
185 function = js_stack['function'] | |
186 if script: | |
187 frameinfo = dict(filename = script.filename, | |
188 lineno = js_stack['lineno'], | |
189 name = '<module>') | |
190 elif function and not function.is_python: | |
191 frameinfo = dict(filename = function.filename, | |
192 lineno = js_stack['lineno'], | |
193 name = function.name) | |
194 else: | |
195 frameinfo = None | |
196 if frameinfo: | |
197 lines.insert(0, STACK_LINE % frameinfo) | |
198 try: | |
199 filelines = open(frameinfo['filename']).readlines() | |
200 line = filelines[frameinfo['lineno'] - 1].strip() | |
201 lines.insert(1, " %s" % line) | |
202 except Exception: | |
203 pass | |
204 js_stack = js_stack['caller'] | |
205 lines.insert(0, "Traceback (most recent call last):") | |
206 return '\n'.join(lines) | |
207 | |
208 def jsexposed(name=None, on=None): | |
209 """ | |
210 Decorator used to expose the decorated function or method to | |
211 untrusted JS. | |
212 | |
213 'name' is an optional alternative name for the function. | |
214 | |
215 'on' is an optional SafeJsObjectWrapper that the function can be | |
216 automatically attached as a property to. | |
217 """ | |
218 | |
219 if callable(name): | |
220 func = name | |
221 func.__jsexposed__ = True | |
222 return func | |
223 | |
224 def make_exposed(func): | |
225 if name: | |
226 func.__name__ = name | |
227 func.__jsexposed__ = True | |
228 if on: | |
229 on[func.__name__] = func | |
230 return func | |
231 return make_exposed | |
232 | |
233 class JsExposedObject(object): | |
234 """ | |
235 Trivial base/mixin class for any Python classes that choose to | |
236 expose themselves to JS code. | |
237 """ | |
238 | |
239 pass | |
240 | |
241 class JsSandbox(object): | |
242 """ | |
243 A JS runtime and associated functionality capable of securely | |
244 loading and executing scripts. | |
245 """ | |
246 | |
11 | 247 def __init__(self, fs, watchdog=watchdog): |
0 | 248 rt = pydermonkey.Runtime() |
249 cx = rt.new_context() | |
2
b6f9d743a2b5
Refined require() implementation.
Atul Varma <varmaa@toolness.com>
parents:
1
diff
changeset
|
250 root_proto = cx.new_object() |
b6f9d743a2b5
Refined require() implementation.
Atul Varma <varmaa@toolness.com>
parents:
1
diff
changeset
|
251 cx.init_standard_classes(root_proto) |
b6f9d743a2b5
Refined require() implementation.
Atul Varma <varmaa@toolness.com>
parents:
1
diff
changeset
|
252 root = cx.new_object(None, root_proto) |
0 | 253 |
254 cx.set_operation_callback(self._opcb) | |
255 cx.set_throw_hook(self._throwhook) | |
256 watchdog.add_context(cx) | |
257 | |
11 | 258 self.fs = fs |
0 | 259 self.rt = rt |
260 self.cx = cx | |
261 self.curr_exc = None | |
262 self.py_stack = None | |
263 self.js_stack = None | |
11 | 264 self.__modules = {} |
0 | 265 self.__py_to_js = {} |
266 self.__type_protos = {} | |
11 | 267 self.__globals = {} |
268 self.__root_proto = root_proto | |
0 | 269 self.root = self.wrap_jsobject(root, root) |
270 | |
11 | 271 def set_globals(self, **globals): |
16 | 272 """ |
273 Sets the global properties for the root object and all global | |
274 scopes (e.g., SecurableModules). This should be called before | |
275 any scripts are executed. | |
276 """ | |
277 | |
11 | 278 self.__globals.update(globals) |
279 self._install_globals(self.root) | |
280 | |
0 | 281 def finish(self): |
282 """ | |
283 Cleans up all resources used by the sandbox, breaking any reference | |
284 cycles created due to issue #2 in pydermonkey: | |
285 | |
286 http://code.google.com/p/pydermonkey/issues/detail?id=2 | |
287 """ | |
288 | |
289 for jsobj in self.__py_to_js.values(): | |
290 self.cx.clear_object_private(jsobj) | |
291 del self.__py_to_js | |
292 del self.__type_protos | |
293 del self.curr_exc | |
294 del self.py_stack | |
295 del self.js_stack | |
296 del self.cx | |
297 del self.rt | |
298 | |
299 def _opcb(self, cx): | |
300 # Don't do anything; if a keyboard interrupt was triggered, | |
301 # it'll get raised here automatically. | |
302 pass | |
303 | |
304 def _throwhook(self, cx): | |
305 curr_exc = cx.get_pending_exception() | |
306 if self.curr_exc != curr_exc: | |
307 self.curr_exc = curr_exc | |
308 self.py_stack = traceback.extract_stack() | |
309 self.js_stack = cx.get_stack() | |
310 | |
311 def __wrap_pycallable(self, func, pyproto=None): | |
312 if func in self.__py_to_js: | |
313 return self.__py_to_js[func] | |
314 | |
315 if hasattr(func, '__name__'): | |
316 name = func.__name__ | |
317 else: | |
318 name = "" | |
319 | |
320 if pyproto: | |
321 def wrapper(func_cx, this, args): | |
322 try: | |
323 arglist = [] | |
324 for arg in args: | |
325 arglist.append(self.wrap_jsobject(arg)) | |
326 instance = func_cx.get_object_private(this) | |
327 if instance is None or not isinstance(instance, pyproto): | |
328 raise pydermonkey.error("Method type mismatch") | |
329 | |
330 # TODO: Fill in extra required params with | |
331 # pymonkey.undefined? or automatically throw an | |
332 # exception to calling js code? | |
333 return self.wrap_pyobject(func(instance, *arglist)) | |
334 except pydermonkey.error: | |
335 raise | |
336 except Exception: | |
337 raise InternalError() | |
338 else: | |
339 def wrapper(func_cx, this, args): | |
340 try: | |
341 arglist = [] | |
342 for arg in args: | |
343 arglist.append(self.wrap_jsobject(arg)) | |
344 | |
345 # TODO: Fill in extra required params with | |
346 # pymonkey.undefined? or automatically throw an | |
347 # exception to calling js code? | |
348 return self.wrap_pyobject(func(*arglist)) | |
349 except pydermonkey.error: | |
350 raise | |
351 except Exception: | |
352 raise InternalError() | |
353 wrapper.wrapped_pyobject = func | |
354 wrapper.__name__ = name | |
355 | |
356 jsfunc = self.cx.new_function(wrapper, name) | |
357 self.__py_to_js[func] = jsfunc | |
358 | |
359 return jsfunc | |
360 | |
361 def __wrap_pyinstance(self, value): | |
362 pyproto = type(value) | |
363 if pyproto not in self.__type_protos: | |
364 jsproto = self.cx.new_object() | |
365 if hasattr(pyproto, '__jsprops__'): | |
366 define_getter = self.cx.get_property(jsproto, | |
367 '__defineGetter__') | |
368 define_setter = self.cx.get_property(jsproto, | |
369 '__defineSetter__') | |
370 for name in pyproto.__jsprops__: | |
371 prop = getattr(pyproto, name) | |
372 if not type(prop) == property: | |
373 raise TypeError("Expected attribute '%s' to " | |
374 "be a property" % name) | |
375 getter = None | |
376 setter = None | |
377 if prop.fget: | |
378 getter = self.__wrap_pycallable(prop.fget, | |
379 pyproto) | |
380 if prop.fset: | |
381 setter = self.__wrap_pycallable(prop.fset, | |
382 pyproto) | |
383 if getter: | |
384 self.cx.call_function(jsproto, | |
385 define_getter, | |
386 (name, getter)) | |
387 if setter: | |
388 self.cx.call_function(jsproto, | |
389 define_setter, | |
390 (name, setter,)) | |
391 for name in dir(pyproto): | |
392 attr = getattr(pyproto, name) | |
393 if (isinstance(attr, types.UnboundMethodType) and | |
394 hasattr(attr, '__jsexposed__') and | |
395 attr.__jsexposed__): | |
396 jsmethod = self.__wrap_pycallable(attr, pyproto) | |
397 self.cx.define_property(jsproto, name, jsmethod) | |
398 self.__type_protos[pyproto] = jsproto | |
399 return self.cx.new_object(value, self.__type_protos[pyproto]) | |
400 | |
401 def wrap_pyobject(self, value): | |
402 """ | |
403 Wraps the given Python object for export to untrusted JS. | |
404 | |
405 If the Python object isn't of a type that can be exposed to JS, | |
406 a TypeError is raised. | |
407 """ | |
408 | |
409 if (isinstance(value, (int, basestring, float, bool)) or | |
410 value is pydermonkey.undefined or | |
411 value is None): | |
412 return value | |
413 if isinstance(value, SafeJsObjectWrapper): | |
414 # It's already wrapped, just unwrap it. | |
415 return value.wrapped_jsobject | |
416 elif callable(value): | |
417 if not (hasattr(value, '__jsexposed__') and | |
418 value.__jsexposed__): | |
419 raise ValueError("Callable isn't configured for exposure " | |
420 "to untrusted JS code") | |
421 return self.__wrap_pycallable(value) | |
422 elif isinstance(value, JsExposedObject): | |
423 return self.__wrap_pyinstance(value) | |
424 else: | |
425 raise TypeError("Can't expose objects of type '%s' to JS." % | |
426 type(value).__name__) | |
427 | |
428 def wrap_jsobject(self, jsvalue, this=None): | |
429 """ | |
430 Wraps the given pydermonkey.Object for import to trusted | |
431 Python code. If the type is just a primitive, it's simply | |
432 returned, since no wrapping is needed. | |
433 """ | |
434 | |
435 if this is None: | |
436 this = self.root.wrapped_jsobject | |
437 if isinstance(jsvalue, pydermonkey.Function): | |
438 if jsvalue.is_python: | |
439 # It's a Python function, just unwrap it. | |
440 return self.cx.get_object_private(jsvalue).wrapped_pyobject | |
441 return SafeJsFunctionWrapper(self, jsvalue, this) | |
442 elif isinstance(jsvalue, pydermonkey.Object): | |
443 # It's a wrapped Python object instance, just unwrap it. | |
444 instance = self.cx.get_object_private(jsvalue) | |
445 if instance: | |
446 if not isinstance(instance, JsExposedObject): | |
447 raise AssertionError("Object private is not of type " | |
448 "JsExposedObject") | |
449 return instance | |
450 else: | |
451 return SafeJsObjectWrapper(self, jsvalue, this) | |
452 else: | |
453 # It's a primitive value. | |
454 return jsvalue | |
455 | |
456 def new_array(self, *contents): | |
16 | 457 """ |
458 Creates a new JavaScript array with the given contents and | |
459 returns a wrapper for it. | |
460 """ | |
461 | |
0 | 462 array = self.wrap_jsobject(self.cx.new_array_object()) |
463 for item in contents: | |
464 array.push(item) | |
465 return array | |
466 | |
467 def new_object(self, **contents): | |
16 | 468 """ |
469 Creates a new JavaScript object with the given properties and | |
470 returns a wrapper for it. | |
471 """ | |
472 | |
0 | 473 obj = self.wrap_jsobject(self.cx.new_object()) |
474 for name in contents: | |
475 obj[name] = contents[name] | |
476 return obj | |
477 | |
11 | 478 def get_calling_script(self): |
16 | 479 """ |
480 Returns the filename of the current stack's most recent | |
481 JavaScript caller. | |
482 """ | |
483 | |
11 | 484 frame = self.cx.get_stack()['caller'] |
485 curr_script = None | |
486 while frame and curr_script is None: | |
487 if frame['function'] and frame['function'].filename: | |
488 curr_script = frame['function'].filename | |
489 elif frame['script']: | |
490 curr_script = frame['script'].filename | |
491 frame = frame['caller'] | |
492 | |
493 if curr_script is None: | |
494 raise RuntimeError("Can't find calling script") | |
495 return curr_script | |
496 | |
497 def _install_globals(self, object): | |
498 for name in self.__globals: | |
499 object[name] = self.__globals[name] | |
500 object['require'] = self._require | |
501 | |
502 @jsexposed(name='require') | |
503 def _require(self, path): | |
16 | 504 """ |
505 Implementation for the global require() function, implemented | |
506 as per the CommonJS SecurableModule specification: | |
507 | |
508 http://wiki.commonjs.org/wiki/CommonJS/Modules/SecurableModules | |
509 """ | |
510 | |
11 | 511 filename = self.fs.find_module(self.get_calling_script(), path) |
512 if not filename: | |
513 raise pydermonkey.error('Module not found: %s' % path) | |
514 if not filename in self.__modules: | |
515 cx = self.cx | |
516 module = cx.new_object(None, self.__root_proto) | |
517 cx.init_standard_classes(module) | |
518 exports = cx.new_object() | |
519 cx.define_property(module, 'exports', exports) | |
520 self._install_globals(self.wrap_jsobject(module)) | |
521 self.__modules[filename] = self.wrap_jsobject(exports) | |
522 contents = self.fs.open(filename).read() | |
523 cx.evaluate_script(module, contents, filename, 1) | |
524 return self.__modules[filename] | |
0 | 525 |
6
97adec8c8127
Now passing all CommonJS SecurableModule compliance tests.
Atul Varma <varmaa@toolness.com>
parents:
5
diff
changeset
|
526 def run_script(self, contents, filename='<string>', lineno=1, |
11 | 527 callback=None, stderr=sys.stderr): |
0 | 528 """ |
529 Runs the given JS script, returning 0 on success, -1 on failure. | |
530 """ | |
531 | |
532 retval = -1 | |
533 cx = self.cx | |
534 try: | |
535 result = cx.evaluate_script(self.root.wrapped_jsobject, | |
6
97adec8c8127
Now passing all CommonJS SecurableModule compliance tests.
Atul Varma <varmaa@toolness.com>
parents:
5
diff
changeset
|
536 contents, filename, lineno) |
0 | 537 if callback: |
538 callback(self.wrap_jsobject(result)) | |
539 retval = 0 | |
540 except pydermonkey.error, e: | |
11 | 541 params = dict( |
542 stack_trace = format_stack(self.js_stack, self.fs.open), | |
543 error = e.args[1] | |
544 ) | |
545 stderr.write("%(stack_trace)s\n%(error)s\n" % params) | |
0 | 546 except InternalError, e: |
11 | 547 stderr.write("An internal error occurred.\n") |
548 traceback.print_tb(e.exc_info[2], None, stderr) | |
549 stderr.write("%s\n" % e.exc_info[1]) | |
0 | 550 return retval |
1
ab09b8a10876
Added trivial half-baked implementation of securable modules.
Atul Varma <varmaa@toolness.com>
parents:
0
diff
changeset
|
551 |
14 | 552 class HttpFileSystem(object): |
16 | 553 """ |
554 File system through which all resources are loaded over HTTP. | |
555 """ | |
556 | |
14 | 557 def __init__(self, base_url): |
558 self.base_url = base_url | |
559 | |
560 def find_module(self, curr_url, path): | |
561 import urlparse | |
562 | |
563 if path.startswith("."): | |
564 base_url = curr_url | |
565 else: | |
566 base_url = self.base_url | |
567 | |
568 url = "%s.js" % urlparse.urljoin(base_url, path) | |
569 if not url.startswith(self.base_url): | |
570 return None | |
571 return url | |
572 | |
573 def open(self, url): | |
574 import urllib | |
575 | |
576 return urllib.urlopen(url) | |
577 | |
16 | 578 class LocalFileSystem(object): |
579 """ | |
580 File system through which all resources are loaded over the local | |
581 filesystem. | |
582 """ | |
583 | |
11 | 584 def __init__(self, root_dir): |
3
14d8d73774d7
Refactored securable module loader.
Atul Varma <varmaa@toolness.com>
parents:
2
diff
changeset
|
585 self.root_dir = root_dir |
2
b6f9d743a2b5
Refined require() implementation.
Atul Varma <varmaa@toolness.com>
parents:
1
diff
changeset
|
586 |
11 | 587 def find_module(self, curr_script, path): |
14 | 588 import os |
589 | |
11 | 590 if path.startswith("."): |
591 base_dir = os.path.dirname(curr_script) | |
592 else: | |
593 base_dir = self.root_dir | |
2
b6f9d743a2b5
Refined require() implementation.
Atul Varma <varmaa@toolness.com>
parents:
1
diff
changeset
|
594 |
11 | 595 ospath = path.replace('/', os.path.sep) |
596 filename = os.path.join(base_dir, "%s.js" % ospath) | |
597 filename = os.path.normpath(filename) | |
598 if (filename.startswith(self.root_dir) and | |
599 (os.path.exists(filename) and | |
600 not os.path.isdir(filename))): | |
601 return filename | |
602 else: | |
603 return None | |
3
14d8d73774d7
Refactored securable module loader.
Atul Varma <varmaa@toolness.com>
parents:
2
diff
changeset
|
604 |
11 | 605 def open(self, filename): |
606 return open(filename, 'r') |