"""Built-in Unix tools reimplemented in Python so they work on Windows. These are what the OS simulator uses for cat / ls / sha256sum / base64 / xxd / tr / … . External tools (python, openssl, …) are executed via subprocess from :mod:`app.sandbox.terminal`. """ import base64 import binascii import hashlib import os from typing import Optional from app.core.constants import HELP_TEXT def _safe_path(workdir: str, filename: str) -> Optional[str]: """Resolve filename safely within workdir, preventing path traversal. Returns the resolved path if safe, None if traversal detected. """ # Strip leading slashes and replace .. cleaned = filename.lstrip("/\\").replace("..", "_") target = os.path.normpath(os.path.join(workdir, cleaned)) # Verify the resolved path is within workdir real_workdir = os.path.realpath(workdir) real_target = os.path.realpath(target) if not real_target.startswith(real_workdir + os.sep) and real_target != real_workdir: return None return target def shell_builtins(args: list[str], workdir: str) -> Optional[dict]: """Run a built-in command. Returns a ``{stdout, stderr, exitCode}`` result dict, or ``None`` if the tool is not a built-in (caller should fall through to subprocess).""" tool = args[0] if args else "" rest = args[1:] try: if tool == "cat": if not rest: return {"stdout": "", "stderr": "cat: missing filename", "exitCode": 1} out, err = [], "" for fname in rest: p = _safe_path(workdir, fname) if p is None: err += f"cat: {fname}: Path traversal blocked\n" continue if not os.path.isfile(p): err += f"cat: {fname}: No such file\n" continue with open(p, "r", encoding="utf-8", errors="replace") as f: out.append(f.read()) return {"stdout": "\n".join(out), "stderr": err, "exitCode": 0 if not err else 1} if tool == "ls": entries = sorted(os.listdir(workdir)) lines = [] for n in entries: if n.startswith("."): continue p = os.path.join(workdir, n) if os.path.isdir(p): lines.append(f" {n}") else: lines.append(f" {n}") return {"stdout": "\n".join(lines), "stderr": "", "exitCode": 0} if tool == "pwd": return {"stdout": workdir, "stderr": "", "exitCode": 0} if tool == "echo": return {"stdout": " ".join(rest), "stderr": "", "exitCode": 0} if tool == "whoami": return {"stdout": os.environ.get("USERNAME") or os.environ.get("USER") or "student", "stderr": "", "exitCode": 0} if tool == "clear": return {"stdout": "\x1b[2J\x1b[H", "stderr": "", "exitCode": 0, "clear": True} if tool == "help": return {"stdout": HELP_TEXT, "stderr": "", "exitCode": 0} if tool in ("sha256sum", "md5sum", "sha1sum"): algo = {"sha256sum": "sha256", "md5sum": "md5", "sha1sum": "sha1"}[tool] if not rest: return {"stdout": "", "stderr": f"{tool}: missing filename", "exitCode": 1} out, err = [], "" for fname in rest: p = _safe_path(workdir, fname) if p is None: err += f"{tool}: {fname}: Path traversal blocked\n" continue if not os.path.isfile(p): err += f"{tool}: {fname}: No such file\n" continue with open(p, "rb") as f: h = hashlib.new(algo, f.read()).hexdigest() out.append(f"{h} {fname}") return {"stdout": "\n".join(out), "stderr": err, "exitCode": 0 if not err else 1} if tool == "base64": decode = "-d" in rest args2 = [a for a in rest if a != "-d"] if not args2: import sys data = sys.stdin.read() if not sys.stdin.isatty() else b"" if decode: return {"stdout": base64.b64decode(data).decode("utf-8", "replace"), "stderr": "", "exitCode": 0} return {"stdout": base64.b64encode(data).decode(), "stderr": "", "exitCode": 0} out, err = [], "" for fname in args2: p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_")) if not os.path.isfile(p): err += f"base64: {fname}: No such file\n" continue with open(p, "rb") as f: data = f.read() if decode: out.append(base64.b64decode(data).decode("utf-8", "replace")) else: out.append(base64.b64encode(data).decode()) return {"stdout": "\n".join(out), "stderr": err, "exitCode": 0 if not err else 1} if tool == "xxd": if not rest: return {"stdout": "", "stderr": "xxd: missing filename", "exitCode": 1} p = os.path.join(workdir, rest[0].lstrip("/\\").replace("..", "_")) if not os.path.isfile(p): return {"stdout": "", "stderr": f"xxd: {rest[0]}: No such file", "exitCode": 1} with open(p, "rb") as f: data = f.read() return {"stdout": binascii.hexlify(data).decode(), "stderr": "", "exitCode": 0} if tool == "tr": if len(rest) < 2: return {"stdout": "", "stderr": "tr: usage: tr SET1 SET2", "exitCode": 1} set1, set2 = rest[0], rest[1] if len(rest) > 2: src = " ".join(rest[2:]) for a, b in zip(set1, set2): src = src.replace(a, b) return {"stdout": src, "stderr": "", "exitCode": 0} return {"stdout": "", "stderr": "tr: no input", "exitCode": 1} if tool == "file": if not rest: return {"stdout": "", "stderr": "file: missing filename", "exitCode": 1} fname = rest[0] p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_")) if not os.path.isfile(p): return {"stdout": "", "stderr": f"file: {fname}: No such file", "exitCode": 1} with open(p, "rb") as f: header = f.read(8) if header.startswith(b"\xff\xd8\xff"): return {"stdout": f"{fname}: JPEG image data, JFIF standard 1.01", "stderr": "", "exitCode": 0} if header.startswith(b"\x89PNG\r\n\x1a\n"): return {"stdout": f"{fname}: PNG image data, 8-bit/color RGBA, non-interlaced", "stderr": "", "exitCode": 0} if header.startswith(b"PK\x03\x04"): return {"stdout": f"{fname}: Zip archive data, at least v2.0 to extract", "stderr": "", "exitCode": 0} with open(p, "rb") as f: content = f.read() if b"PK\x03\x04" in content: if content.startswith(b"\xff\xd8\xff"): return {"stdout": f"{fname}: JPEG image data (with appended Zip archive data)", "stderr": "", "exitCode": 0} return {"stdout": f"{fname}: Zip archive data (appended)", "stderr": "", "exitCode": 0} try: content.decode("utf-8") return {"stdout": f"{fname}: ASCII text", "stderr": "", "exitCode": 0} except UnicodeDecodeError: return {"stdout": f"{fname}: data", "stderr": "", "exitCode": 0} if tool == "strings": if not rest: return {"stdout": "", "stderr": "strings: missing filename", "exitCode": 1} fname = rest[0] p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_")) if not os.path.isfile(p): return {"stdout": "", "stderr": f"strings: {fname}: No such file", "exitCode": 1} with open(p, "rb") as f: data = f.read() out = [] curr = [] for b in data: if 32 <= b <= 126 or b == 10 or b == 13: curr.append(chr(b)) else: if len(curr) >= 4: out.append("".join(curr).strip()) curr = [] if len(curr) >= 4: out.append("".join(curr).strip()) out = [line for line in out if line] return {"stdout": "\n".join(out[:1000]), "stderr": "", "exitCode": 0} if tool in ("unzip", "unzip-file"): if not rest: return {"stdout": "", "stderr": "unzip: missing filename", "exitCode": 1} fname = rest[0] args_clean = [a for a in rest if not a.startswith("-")] if not args_clean: return {"stdout": "", "stderr": "unzip: missing filename", "exitCode": 1} fname = args_clean[0] p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_")) if not os.path.isfile(p): return {"stdout": "", "stderr": f"unzip: {fname}: No such file", "exitCode": 1} import zipfile try: out_lines = [f"Archive: {fname}"] with zipfile.ZipFile(p, 'r') as zip_ref: # Validate all paths before extraction (Zip Slip prevention) for info in zip_ref.infolist(): target = os.path.realpath(os.path.join(workdir, info.filename)) if not target.startswith(os.path.realpath(workdir) + os.sep) and target != os.path.realpath(workdir): return { "stdout": "", "stderr": f"unzip: blocked path traversal: {info.filename}", "exitCode": 1, } # Safe to extract zip_ref.extractall(workdir) for info in zip_ref.infolist(): out_lines.append(f" extracting: {info.filename}") return {"stdout": "\n".join(out_lines), "stderr": "", "exitCode": 0} except Exception as e: return {"stdout": "", "stderr": "unzip: error occurred", "exitCode": 1} except Exception as e: return {"stdout": "", "stderr": f"{tool}: {e}", "exitCode": 1} return None # not a builtin