#!/usr/bin/env python3 """Tests for the server-side block compiler. Two jobs: 1. every fixture the editor produces must compile to JavaScript that actually parses 2. a hostile project must not be able to inject code The second is the point of compiling on the host at all, so it gets the most attention. """ from __future__ import annotations import json import re import subprocess import sys import tempfile from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from compiler import CompileError, Compiler, compile_project # noqa: E402 HERE = Path(__file__).parent FIXTURES = HERE / "fixtures" failures = 0 checks = 0 def check(condition: bool, what: str) -> None: global failures, checks checks += 1 if condition: print(f" ok: {what}") else: failures += 1 print(f" FAIL: {what}") def node_check(source: str, label: str) -> bool: with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) as f: f.write(source) path = f.name try: r = subprocess.run(["node", "--check", path], capture_output=True, text=True) if r.returncode != 0: print(f" node: {r.stderr.strip().splitlines()[:3]}") return r.returncode == 0 finally: Path(path).unlink(missing_ok=True) # --- fixtures --------------------------------------------------------------- print("== fixtures") if not FIXTURES.exists(): print(" !! no fixtures; run app/tools/export_fixtures.gd first") raise SystemExit(1) for path in sorted(FIXTURES.glob("*.bbproj")): project = json.loads(path.read_text()) source, warnings = compile_project(project, path.stem) check(bool(source), f"{path.stem}: compiled") check(node_check(source, path.stem), f"{path.stem}: output parses as JavaScript") check("{" not in source or "}" in source, f"{path.stem}: braces balanced enough to parse") for w in warnings: print(f" warning: {w}") # The orphan fixture must warn rather than silently dropping a stack. orphan = json.loads((FIXTURES / "orphan.bbproj").read_text()) _, orphan_warnings = compile_project(orphan, "orphan") check(any("event block" in w for w in orphan_warnings), "orphan stack produces a warning") # ping_pong registers its command. pp, _ = compile_project(json.loads((FIXTURES / "ping_pong.bbproj").read_text()), "pp") check("registerCommands" in pp, "command hat emits registration") check('"replies with pong"' in pp, "command help text is used as the description") # Command options become variables, assigned from the words typed after the command. co, _ = compile_project(json.loads((FIXTURES / "command_options.bbproj").read_text()), "co") check("let v_count = 0;" in co and "let v_sides = 0;" in co, "command options are declared as variables") check("v_count = __bb.commandOption(__args, 0, 2);" in co and "v_sides = __bb.commandOption(__args, 1, 2);" in co, "command options are read positionally from the command's args") check(co.index("if (!(__bb.eq") < co.index("v_count = __bb.commandOption"), "options are only read once the command name has matched") check('args: "count sides"' in co, "option names are registered as the usage hint") check(len(re.findall(r"^let v_count = 0;$", co, re.M)) == 1, "an option used as a variable is declared exactly once") # --- hostile input ---------------------------------------------------------- # These are the cases that justify compiling on the host instead of trusting the client. print("\n== injection attempts") def compile_nodes(scripts: list) -> str: return Compiler().compile({"scripts": scripts}, "evil") def code_only(js: str) -> str: """Blank out every string literal, leaving only executable code. Checking that a payload is 'absent from the output' is too weak — it appears verbatim inside the string literal it was escaped into, which is exactly correct behaviour. What matters is whether it can appear as *code*, so strip the literals and look at the rest. """ out: list[str] = [] i, n = 0, len(js) while i < n: ch = js[i] if ch in ('"', "'", "`"): quote = ch i += 1 while i < n: if js[i] == "\\": i += 2 continue if js[i] == quote: i += 1 break i += 1 out.append('""') continue out.append(ch) i += 1 return "".join(out) # 1. Code in a text field must stay a string literal. evil_text = compile_nodes([{ "opcode": "event_ready", "id": "a", "fields": {}, "next": {"opcode": "message_send", "id": "b", "fields": {"TEXT": '"); process.exit(1); //'}}, }]) check("process.exit" not in code_only(evil_text), "quote-breaking text field cannot inject a statement") check(node_check(evil_text, "evil_text"), "escaped output still parses") # 2. Numeric fields must not smuggle an expression. evil_num = compile_nodes([{ "opcode": "event_ready", "id": "a", "fields": {}, "next": {"opcode": "control_wait", "id": "b", "fields": {"SECS": "1); require('fs').rmSync('/', {recursive:true}); ("}}, }]) check("rmSync" not in code_only(evil_num), "non-numeric number field cannot inject a call") check(node_check(evil_num, "evil_num"), "escaped numeric output still parses") # 3. Dropdowns must be restricted to catalogue values. evil_menu = compile_nodes([{ "opcode": "event_ready", "id": "a", "fields": {}, "next": {"opcode": "sensing_current", "id": "b", "fields": {"WHAT": "__proto__"}}, }]) check("__proto__" not in evil_menu, "dropdown falls back to a known option") # 4. Variable names become identifiers, never raw code. evil_var = compile_nodes([{ "opcode": "event_ready", "id": "a", "fields": {}, "next": {"opcode": "data_setvariableto", "id": "b", "fields": {"VARIABLE": "x = require('child_process'); y", "VALUE": "1"}}, }]) # The sanitised name still *contains* the letters "child_process" — that's fine, it's one # identifier. What matters is that it is only an identifier, and introduces no new require. declared = re.findall(r"^let ([^ ]+) =", evil_var, re.M) check(bool(declared) and all(re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", d) for d in declared), "variable name is sanitised to a bare identifier") check(code_only(evil_var).count("require(") == 2, "no require() beyond the two header imports") check(node_check(evil_var, "evil_var"), "sanitised variable output parses") # 4b. So do command option names, which reach the same identifier path by another route. evil_options = compile_nodes([{ "opcode": "event_command", "id": "a", "fields": {"NAME": "go", "HELP": "go", "OPTIONS": "ok require('child_process') y"}, "next": {"opcode": "message_reply", "id": "b", "fields": {"TEXT": "hi"}}, }]) declared = re.findall(r"^let ([^ ]+) =", evil_options, re.M) check(bool(declared) and all(re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", d) for d in declared), "option name is sanitised to a bare identifier") check(code_only(evil_options).count("require(") == 2, "an option name introduces no require() beyond the two header imports") check(node_check(evil_options, "evil_options"), "sanitised option output parses") # 4c. Options have to be known while compiling, so a reporter in the slot is refused loudly # rather than quietly producing a command whose variables never fill in. _, option_warnings = compile_project({"scripts": [{ "opcode": "event_command", "id": "a", "fields": {"NAME": "go", "HELP": "go"}, "inputs": {"OPTIONS": {"opcode": "message_text", "id": "b", "fields": {}}}, }]}, "opts") check(any("options" in w for w in option_warnings), "a block in the options slot warns") # 5. Block ids are echoed into __bb.step("…"); they must not close the string. evil_id = compile_nodes([{ "opcode": "event_ready", "id": 'a"); process.exit(1); //', "fields": {}, "next": {"opcode": "message_send", "id": "b", "fields": {"TEXT": "hi"}}, }]) check("process.exit" not in code_only(evil_id), "block id cannot break out of its string literal") check(node_check(evil_id, "evil_id"), "sanitised id output parses") # 6. Unknown opcodes are rejected outright. try: compile_nodes([{"opcode": "totally_made_up", "id": "a", "fields": {}}]) check(False, "unknown opcode is rejected") except CompileError: check(True, "unknown opcode is rejected") # 7. Absurdly deep nesting is refused rather than blowing the stack. deep: dict = {"opcode": "message_send", "id": "z", "fields": {"TEXT": "x"}} for _ in range(400): deep = {"opcode": "control_if", "id": "n", "fields": {}, "substacks": {"SUBSTACK": deep}} try: compile_nodes([{"opcode": "event_ready", "id": "a", "fields": {}, "next": deep}]) check(False, "excessive nesting is refused") except (CompileError, RecursionError): check(True, "excessive nesting is refused") # 8. Malformed projects give a readable error, not a traceback. for bad in ({}, {"scripts": "nope"}, {"scripts": [42]}): try: Compiler().compile(bad, "bad") check(False, f"malformed project rejected: {bad}") except CompileError: check(True, f"malformed project rejected: {bad}") print(f"\nchecks: {checks}, failures: {failures}") raise SystemExit(1 if failures else 0)