CAT-SOOP is a flexible, programmable learning management system based on the Python programming language. https://catsoop.mit.edu
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.
 
 
 

261 lines
7.4 KiB

  1. # This file is part of CAT-SOOP
  2. # Copyright (c) 2011-2020 by The CAT-SOOP Developers <catsoop-dev@mit.edu>
  3. #
  4. # This program is free software: you can redistribute it and/or modify it under
  5. # the terms of the GNU Affero General Public License as published by the Free
  6. # Software Foundation, either version 3 of the License, or (at your option) any
  7. # later version.
  8. #
  9. # This program is distributed in the hope that it will be useful, but WITHOUT
  10. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  11. # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
  12. # details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. import os
  17. import re
  18. import ast
  19. import logging
  20. LOGGER = logging.getLogger("cs")
  21. def _execfile(*args):
  22. fn = args[0]
  23. with open(fn) as f:
  24. c = compile(f.read(), fn, "exec")
  25. exec(c, *args[1:])
  26. def prep_code(code, test, **kwargs):
  27. # code is whatever code we need to test; test is the dictionary describing
  28. # what test we should be running
  29. code = code.strip()
  30. if test["variable"] is not None:
  31. footer = (
  32. "_catsoop_answer = %s\nimport sys\nsys.settrace(None)" % test["variable"]
  33. )
  34. else:
  35. footer = None
  36. code = "\n\n".join(
  37. (
  38. "import os\nos.unlink(__file__)",
  39. kwargs["csq_code_pre"],
  40. test["code_pre"],
  41. code,
  42. "pass",
  43. kwargs["csq_code_post"],
  44. test["code"],
  45. footer,
  46. )
  47. )
  48. return code
  49. def sandbox_run_code(
  50. context,
  51. code,
  52. options,
  53. count_opcodes=False,
  54. opcode_limit=None,
  55. result_as_string=False,
  56. ):
  57. s = context.get("csq_python_sandbox", "remote")
  58. sandbox_file = os.path.join(
  59. context["cs_fs_root"], "__QTYPES__", "pythoncode", "__SANDBOXES__", "%s.py" % s
  60. )
  61. LOGGER.info("[pythoncode.sandbox.base] sandbox_file=%s" % sandbox_file)
  62. opts = dict(DEFAULT_OPTIONS)
  63. opts.update(context.get("csq_sandbox_options", {}))
  64. opts.update(options)
  65. sandbox = dict(context)
  66. _execfile(sandbox_file, sandbox)
  67. try:
  68. return sandbox["run_code"](
  69. context,
  70. code,
  71. opts,
  72. count_opcodes=count_opcodes,
  73. opcode_limit=opcode_limit,
  74. result_as_string=result_as_string,
  75. )
  76. except Exception as err:
  77. LOGGER.error(
  78. "[pythoncode.sandbox.base] Failed to run_code, opts=%s, err=%s"
  79. % (opts, err)
  80. )
  81. raise
  82. def fix_error_msg(fname, err, offset, sub):
  83. sublen = sub.count("\n")
  84. def subber(m):
  85. g = m.groups()
  86. out = g[0]
  87. lineno = int(g[1])
  88. if lineno > (offset + sublen + 1):
  89. # error in test code
  90. out += "File <Test Code>, line %d%s" % (lineno, g[2])
  91. elif lineno < offset:
  92. # error in test code
  93. out += "File <Test Code Preamble>, line %d%s" % (lineno, g[2])
  94. else:
  95. # error in user-submitted code
  96. out += "File <User-Submitted Code>, line %d%s" % (lineno - offset, g[2])
  97. return out
  98. error_regex = re.compile('(.*?)File "%s", line ([0-9]+)(,?[\n]*)' % fname)
  99. err = error_regex.sub(subber, err)
  100. err = err.replace(fname, "TEST FILE")
  101. e = err.split("\n")
  102. if len(e) > 15:
  103. err = "...ERROR OUTPUT TRUNCATED...\n\n" + "\n".join(e[-10:])
  104. err = err.replace("[Subprocess exit code: 1]", "")
  105. err = re.compile('(.*?)File "app_main.py", line ([0-9]+)(,?[^\n]*)\n').sub("", err)
  106. return err
  107. DEFAULT_OPTIONS = {
  108. "CPUTIME": 1,
  109. "CLOCKTIME": 1,
  110. "MEMORY": 32 * 1024 ** 2,
  111. "FILESIZE": 0,
  112. "BADIMPORT": [],
  113. "BADVAR": [],
  114. "FILES": [],
  115. "STDIN": "",
  116. }
  117. def truncate(out, name="OUTPUT"):
  118. outlines = out.split("\n")
  119. if len(outlines) > 15:
  120. outlines = outlines[:15] + ["...%s TRUNCATED..." % name]
  121. out = "\n".join(outlines)
  122. if len(out) >= 5000:
  123. out = out[:5000] + "\n\n...%s TRUNCATED..." % name
  124. return out
  125. def sandbox_run_test(context, code, test):
  126. options = dict(DEFAULT_OPTIONS)
  127. options.update(context.get("csq_sandbox_options", {}))
  128. options.update(test.get("sandbox_options", {}))
  129. safe = safety_check(code, options["BADIMPORT"], options["BADVAR"])
  130. if isinstance(safe, tuple):
  131. return ("", ("On line %d: " % safe[0]) + safe[1], "")
  132. results = sandbox_run_code(
  133. context,
  134. prep_code(code, test, **context),
  135. options,
  136. count_opcodes=test["count_opcodes"],
  137. opcode_limit=test["opcode_limit"],
  138. result_as_string=test["result_as_string"],
  139. )
  140. err = truncate(results["err"], "ERROR OUTPUT")
  141. err = fix_error_msg(
  142. results["fname"], err, context["csq_code_pre"].count("\n") + 2, code
  143. )
  144. out = truncate(results["out"], "OUTPUT")
  145. return out.strip(), err.strip(), results["info"]
  146. def _ast_downward_search(node, testfunc):
  147. """
  148. recursive search through AST. if a node causes testfunc to return true,
  149. then return that. otherwise, return None
  150. """
  151. out = []
  152. if testfunc(node):
  153. out.append(node)
  154. for i in node._hz_children:
  155. out.extend(_ast_downward_search(i, testfunc))
  156. return out
  157. def _prepare_ast(tree, parent=None):
  158. """
  159. stupid little piece of code to add a parent pointer to all nodes
  160. """
  161. tree._hz_parent = parent
  162. tree._hz_children = list(ast.iter_child_nodes(tree))
  163. for i in tree._hz_children:
  164. _prepare_ast(i, tree)
  165. def _blacklist_variable(var, blacklist=None):
  166. blacklist = blacklist or []
  167. if var.id in blacklist:
  168. return "Disallowed variable name: %s" % var.id
  169. else:
  170. return None
  171. def _blacklist_import(imp, blacklist=None):
  172. blacklist = blacklist or []
  173. if isinstance(imp, ast.ImportFrom):
  174. for i in blacklist:
  175. if re.match(i, imp.module):
  176. return "Disallowed import from %s" % imp.module
  177. else:
  178. # is Import instance
  179. for n in [i.name for i in imp.names]:
  180. for i in blacklist:
  181. if re.match(i, n):
  182. return "Disallowed import: %s" % n
  183. def safety_check(code, bad_imports=None, bad_variables=None):
  184. """
  185. Return None if the code is fine; otherise, return (lineno, errmsg)
  186. """
  187. code = code.replace("\r\n", "\n").strip() # whitespace issues...
  188. # parse code down into AST. return error message on failure
  189. try:
  190. tree = ast.parse(code)
  191. _prepare_ast(tree) # recursively add parent/child pointers to each node
  192. except:
  193. return "SYNTAX ERROR" # TODO: replace this with real error message
  194. # collect all instances of Name not contained in an Attribute object
  195. search = _ast_downward_search
  196. vars = search(
  197. tree,
  198. lambda n: (
  199. isinstance(n, ast.Name)
  200. and (not isinstance(n._hz_parent, ast.Attribute))
  201. and (
  202. not (isinstance(n._hz_parent, ast.Assign) and n in n._hz_parent.targets)
  203. )
  204. ),
  205. )
  206. for var in vars or []:
  207. res = _blacklist_variable(var, bad_variables)
  208. if res is not None:
  209. return (var.lineno, res)
  210. # collect all imports
  211. imports = _ast_downward_search(
  212. tree, lambda n: (isinstance(n, ast.Import) or isinstance(n, ast.ImportFrom))
  213. )
  214. for imp in imports or []:
  215. res = _blacklist_import(imp, bad_imports)
  216. if res is not None:
  217. return (imp.lineno, res)