diff --git a/.github/workflows/python-wasm.yml b/.github/workflows/python-wasm.yml new file mode 100644 index 0000000..e62eb29 --- /dev/null +++ b/.github/workflows/python-wasm.yml @@ -0,0 +1,38 @@ +name: Python WASM +on: + push: + paths: + - 'python-wasm/**' + - 'typescript/tests/fixtures/wasm-api.wasm' + - '.github/workflows/python-wasm.yml' + pull_request: + paths: + - 'python-wasm/**' + - 'typescript/tests/fixtures/wasm-api.wasm' + - '.github/workflows/python-wasm.yml' + +jobs: + wasmer: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.9' + - run: python -m pip install ./python-wasm + - name: Offline API and downloader tests + run: python -m unittest discover -s python-wasm/tests -v + - name: Download known working API release + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python - <<'PY' + import os + from r2pipe_wasm import DownloadManager + manager = DownloadManager(token=os.environ['GITHUB_TOKEN']) + path = manager.download('6.1.4') + with open(os.environ['GITHUB_ENV'], 'a') as env: + env.write(f'R2PIPE_WASM={path}\n') + PY + - name: Real radare2 file sharing and isolation + run: python -m unittest discover -s python-wasm/tests -v diff --git a/README.md b/README.md index 099d0b2..8f048c5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ The most popular implementations of r2pipe are: * NodeJS / TypeScript * Python +* [Python WASM / Wasmer](python-wasm/README.md) * Swift * .NET / C# (`dotnet/README.md`) * C/Nim/Vala/C++ diff --git a/python-wasm/.gitignore b/python-wasm/.gitignore new file mode 100644 index 0000000..f47a5b3 --- /dev/null +++ b/python-wasm/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.egg-info/ +build/ +dist/ diff --git a/python-wasm/LICENSE b/python-wasm/LICENSE new file mode 100644 index 0000000..64b846e --- /dev/null +++ b/python-wasm/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 pancake + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/python-wasm/README.md b/python-wasm/README.md new file mode 100644 index 0000000..b083ab3 --- /dev/null +++ b/python-wasm/README.md @@ -0,0 +1,137 @@ +# r2pipe-wasm (Python + Wasmer) + +Run radare2 inside Python as a WebAssembly instance, without a native radare2 +installation or a subprocess. This is a separate package (`r2pipe_wasm`) with +an r2pipe-like `open`, `cmd`, `cmdj`, `quit` and context-manager interface. + +The backend uses Wasmer's Python SDK and the `wasi-api` reactor build, calling +`r_core_new`, `r_core_cmd_str`, `r_core_file_open`, `r_core_bin_load` and +`r_core_free` directly. Command strings and results are allocated/freed in guest +memory. Each connection owns its own instance and core. + +## Install + +From this repository: + +```sh +python3.9 -m venv .venv +. .venv/bin/activate +pip install ./python-wasm +``` + +**Runtime compatibility:** Wasmer's published Python SDK is 1.1.0 and its native +wheels have limited Python/platform coverage. CPython 3.9 on Linux x86_64 is +verified here. A newer Python may install a placeholder package that raises +`ImportError`; installing successfully alone does not prove runtime support. +Use a supported native wheel or build the Wasmer Python SDK and matching +Cranelift extension yourself. This package does not fall back to a CLI/runtime +subprocess. Release discovery/downloading uses only the Python standard library +and can be imported without loading Wasmer. + +## Analyze a file + +```python +from pathlib import Path +import r2pipe_wasm as r2pipe + +# Only this directory is shared. The filename is a path in the guest environment. +samples = Path("samples").resolve() +with r2pipe.open("/samples/program", version="6.1.4", + preopens={"/samples": samples}) as r2: + print(r2.cmdj("ij")) + r2.cmd("aaa") + print(r2.cmdj("aflj")) + print(r2.cmd("pd 10")) +``` + +By default `open()` downloads/caches the **newest stable release with a +`wasi-api` asset**. A newer release without that asset is skipped (for example, +6.2.4 has no API asset, while 6.2.2 does). CLI `wasi` and `wasi-browser` builds +are not substitutes. Compilation takes place when opening each connection; +keep the connection open for multiple commands. + +**Known upstream release issue:** the published 6.2.2 API module traps inside +`r_core_new()` (`ht_pp_find_kv` during config initialization), reproduced with +both Wasmer and Node WASI. The examples pin **6.1.4**, verified end to end. +The default still selects the newest stable API asset and propagates guest +errors; it does not silently fall back to an older version. Use `version=` or +`wasm=` to select a working release/build. + +## List releases, choose a version, download once + +```python +from r2pipe_wasm import DownloadManager, api_asset, open + +manager = DownloadManager(cache_dir=".cache/radare2") +for release in manager.releases(): # paginates the GitHub releases API + asset = api_asset(release) + print(release["tag_name"], asset["name"] if asset else "no API build") + +print(manager.select()["tag_name"]) # newest stable API release +wasm = manager.download("6.1.4") # exact tag, or "latest" (default) +with open(wasm=wasm) as r2: # local module: no network requests + print(r2.cmd("?V")) + +# Or select a release directly when opening: +with open(version="6.1.4", manager=manager) as r2: + print(r2.cmd("?V")) +``` + +`releases()` includes prereleases and releases without WASM assets; `select()` +and `download()` reject explicit tags without an API build. Explicit prerelease +tags are allowed. GitHub HTTP errors (including rate limits) propagate to the +caller. An optional `token=` is sent only to the GitHub API, never to the guest +or release download URL. No host environment variables are inherited by WASI. + +The default cache is `$XDG_CACHE_HOME/r2pipe-wasm` or `~/.cache/r2pipe-wasm`. +Downloads use temporary files and an atomic replacement, validate the WASM +header, and verify GitHub's SHA-256 asset digest when provided. Archive members +are never extracted by their paths. `download(force=True)` refreshes a cached +module. Version selection still contacts GitHub on a cache hit; pass `wasm=` +for fully offline operation. + +## Share files and save results + +`preopens` maps **guest absolute directory → host directory**. There are no +preopened host directories by default, and opening a host path does not share it +automatically. Mappings expose the directory's contents with read/write access +subject to host permissions; use a dedicated directory or copies for inputs. +The initial binary is opened read-only, but commands can write shared files. + +```python +from pathlib import Path +import r2pipe_wasm + +shared = Path("work").resolve() +shared.mkdir(exist_ok=True) +(shared / "sample.bin").write_bytes(bytes.fromhex("9090c3")) + +with r2pipe_wasm.open("/work/sample.bin", wasm="radare2.wasm", + preopens={"/work": shared}) as r2: + r2.cmd("e asm.arch=x86; e asm.bits=64") + print(r2.cmd("pd 3")) + r2.cmd("px 3 > /work/hexdump.txt") + +print((shared / "hexdump.txt").read_text()) +``` + +The WASM guest has **no network or system-program execution capability**: +only WASI Preview1 imports are accepted, all socket calls return `ENOTSUP`, and +no HTTP/browser callbacks or WASIX/process-spawning imports are supplied. +Commands such as `!sh`, native debugging and network IO cannot use host services. +The host-side download manager uses HTTPS independently of the guest. +WASI stdout/stderr remain available; this is a synchronous analysis API, not an +interactive terminal. Use `quit()`/`close()` or a context manager to release it. +`cmdj()` raises `JSONDecodeError` for invalid JSON; commands after closing fail. + +## Tests + +```sh +python -m unittest discover -s python-wasm/tests -v +# Also run the real WASI module, file sharing and isolation checks: +R2PIPE_WASM=/absolute/path/to/radare2.wasm \ + python -m unittest discover -s python-wasm/tests -v +``` + +The offline runtime tests reuse the repository's small TypeScript WASM API +fixture. Downloader tests do not require Wasmer or network access. diff --git a/python-wasm/pyproject.toml b/python-wasm/pyproject.toml new file mode 100644 index 0000000..ef962a8 --- /dev/null +++ b/python-wasm/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "r2pipe-wasm" +version = "0.1.0" +description = "In-process radare2 WASI API bindings using Wasmer" +readme = "README.md" +requires-python = ">=3.9" +license = {text = "MIT"} +dependencies = ["wasmer==1.1.0", "wasmer_compiler_cranelift==1.1.0"] + +[tool.setuptools] +packages = ["r2pipe_wasm"] diff --git a/python-wasm/r2pipe_wasm/__init__.py b/python-wasm/r2pipe_wasm/__init__.py new file mode 100644 index 0000000..5f519c5 --- /dev/null +++ b/python-wasm/r2pipe_wasm/__init__.py @@ -0,0 +1,161 @@ +"""An r2pipe-like interface to a radare2 WASI reactor running inside Wasmer.""" + +import json +from pathlib import Path +import threading + +from .releases import DownloadManager, api_asset + +__all__ = ["open", "R2Pipe", "DownloadManager", "api_asset"] + + +class R2Pipe: + """One isolated WASM instance/core; only explicit preopens share host files.""" + + def __init__(self, filename=None, *, wasm=None, version="latest", preopens=None, + manager=None): + # Keep release management usable without the native Wasmer extension. + try: + from wasmer import engine, wasi, Function, ImportObject, Instance, Module, Store + from wasmer_compiler_cranelift import Compiler + except ImportError as error: + raise ImportError( + "Wasmer 1.1 and its Cranelift compiler need a supported native wheel; " + "see python-wasm/README.md (CPython 3.9 on Linux x86_64)." + ) from error + self._lock = threading.RLock() + self._core = 0 + self._instance = None + self._exports = None + self._wasi = None + path = Path(wasm) if wasm is not None else (manager or DownloadManager()).download(version) + store = Store(engine.Universal(Compiler)) + module = Module(store, path.read_bytes()) + # No browser HTTP callbacks, WASIX, process execution, or arbitrary host imports. + for imported in module.imports: + if imported.module != "wasi_snapshot_preview1": + raise ValueError(f"Unsupported WASM import {imported.module}.{imported.name}; " + "use a wasi-api build") + required = {"memory", "malloc", "free", "r_core_new", "r_core_free", + "r_core_cmd_str", "r_core_file_open", "r_core_bin_load"} + missing = required - {export.name for export in module.exports} + if missing: + raise ValueError(f"Missing WASM exports {', '.join(sorted(missing))}; " + "use a wasi-api build") + state = wasi.StateBuilder("radare2") + for guest, host in (preopens or {}).items(): + if not guest.startswith("/") or ".." in guest.split("/") or "\0" in guest: + raise ValueError("Preopen guest paths must be absolute, without '..' or NUL") + host = Path(host).resolve(strict=True) + if not host.is_dir(): + raise ValueError(f"Preopen is not a directory: {host}") + state.map_directory(guest, str(host)) + self._wasi = state.finalize() + imports = ImportObject() + if module.imports: + functions = self._wasi.generate_imports(store, wasi.Version.SNAPSHOT1)[ + "wasi_snapshot_preview1"] + for imported in module.imports: + if imported.name.startswith("sock_"): + # Preview1 ENOTSUP. Also covers sock_accept, absent in Wasmer 1.1. + functions[imported.name] = Function(store, lambda *_args: 58, imported.type) + imports.register("wasi_snapshot_preview1", functions) + self._instance = Instance(module, imports) + self._exports = self._instance.exports + try: + if "_initialize" in {export.name for export in module.exports}: + self._exports._initialize() + self._core = self._exports.r_core_new() + if not self._core: + raise MemoryError("Cannot allocate a radare2 core") + self.cmd("e scr.color=0") + self.cmd("e scr.interactive=false") + if filename is not None: + pointer = self._allocate(str(filename)) + try: + if not self._exports.r_core_file_open(self._core, pointer, 4, 0): + raise OSError(f"Cannot open {filename!r} in WASM; " + "host files require an explicit preopens mapping") + # Signed i64 -1 is UT64_MAX: use the binary's preferred base address. + self._exports.r_core_bin_load(self._core, pointer, -1) + finally: + self._exports.free(pointer) + except BaseException: + self.quit() + raise + + def _allocate(self, text): + if "\0" in text: + raise ValueError("WASM strings cannot contain NUL characters") + data = text.encode("utf-8") + b"\0" + pointer = self._exports.malloc(len(data)) & 0xffffffff + if not pointer: + raise MemoryError("Cannot allocate a WASM string") + try: + # Acquire a fresh buffer after calls which may grow linear memory. + with memoryview(self._exports.memory.buffer) as memory: + memory[pointer:pointer + len(data)] = data + except BaseException: + self._exports.free(pointer) + raise + return pointer + + def _read_string(self, pointer): + chunks = [] + with memoryview(self._exports.memory.buffer) as memory: + # Scan in chunks instead of copying the entire guest heap per command. + while pointer < len(memory): + chunk = bytes(memory[pointer:pointer + 4096]) + end = chunk.find(b"\0") + if end >= 0: + chunks.append(chunk[:end]) + return b"".join(chunks).decode("utf-8", errors="replace") + chunks.append(chunk) + pointer += len(chunk) + raise RuntimeError("Unterminated WASM command output") + + def cmd(self, command): + """Execute a command and return its output; calls are serialized per instance.""" + with self._lock: + if not self._core: + raise RuntimeError("r2pipe WASM instance is closed") + source = self._allocate(command) + output = 0 + try: + output = self._exports.r_core_cmd_str(self._core, source) & 0xffffffff + if not output: + raise RuntimeError("radare2 WASM command returned a null string") + return self._read_string(output) + finally: + if output: + self._exports.free(output) + self._exports.free(source) + + def cmdj(self, command): + """Execute a JSON command; malformed JSON raises JSONDecodeError.""" + return json.loads(self.cmd(command)) + + def quit(self): + """Free the core and release the instance; safe to call more than once.""" + with self._lock: + try: + if self._core: + self._exports.r_core_free(self._core) + finally: + self._core = 0 + self._exports = None + self._instance = None + self._wasi = None + + close = quit + + def __enter__(self): + return self + + def __exit__(self, *_exc): + self.quit() + + +def open(filename=None, **kwargs): + """Open a guest filename (or an empty core), downloading the latest API by default.""" + return R2Pipe(filename, **kwargs) diff --git a/python-wasm/r2pipe_wasm/releases.py b/python-wasm/r2pipe_wasm/releases.py new file mode 100644 index 0000000..3ddca1f --- /dev/null +++ b/python-wasm/r2pipe_wasm/releases.py @@ -0,0 +1,117 @@ +"""GitHub release discovery and downloads, executed by the Python host only.""" + +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import tempfile +from urllib.parse import quote +from urllib.request import Request, urlopen +import zipfile + +API = "https://api.github.com/repos/radareorg/radare2/releases" +WASM_HEADER = b"\0asm\x01\0\0\0" +MAX_ARCHIVE = 128 * 1024 * 1024 +MAX_MODULE = 256 * 1024 * 1024 + + +def api_asset(release): + """Return the WASI reactor asset, or None (CLI/browser builds are unsuitable).""" + return next((asset for asset in release.get("assets", []) + if asset["name"].startswith("radare2-") + and asset["name"].endswith("-wasi-api.zip")), None) + + +class DownloadManager: + """List all public releases and cache their radare2.wasm API modules.""" + + def __init__(self, cache_dir=None, *, token=None, timeout=60): + self.cache_dir = Path(cache_dir) if cache_dir else Path( + os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache") + ) / "r2pipe-wasm" + self.token = token + self.timeout = timeout + + def _request(self, url, *, api=False): + headers = {"User-Agent": "r2pipe-wasm"} + if api: + headers["Accept"] = "application/vnd.github+json" + headers["X-GitHub-Api-Version"] = "2022-11-28" + if self.token: + headers["Authorization"] = "Bearer " + self.token + return urlopen(Request(url, headers=headers), timeout=self.timeout) + + def _json(self, url): + with self._request(url, api=True) as response: + return json.load(response) + + def releases(self): + """Yield release dictionaries, including prereleases and non-WASM releases.""" + page = 1 + while True: + entries = self._json(f"{API}?per_page=100&page={page}") + for release in entries: + if not release["draft"]: + yield release + if len(entries) < 100: + return + page += 1 + + def select(self, version="latest"): + """Select the newest stable API build, or an exact GitHub tag.""" + if version != "latest": + release = self._json(f"{API}/tags/{quote(version, safe='')}") + if not api_asset(release): + raise ValueError(f"Release {version} has no wasi-api build") + return release + for release in self.releases(): + if not release["prerelease"] and api_asset(release): + return release + raise ValueError("No stable radare2 release has a wasi-api build") + + def download(self, version="latest", *, force=False): + """Return a cached Path; resolve the release via GitHub even on cache hits.""" + release = self.select(version) + asset = api_asset(release) + # Asset identity also distinguishes replacement assets and moving tags. + identity = f"{asset['id']}:{asset.get('updated_at', '')}:{asset.get('digest', '')}" + directory = self.cache_dir / hashlib.sha256(identity.encode()).hexdigest() + destination = directory / "radare2.wasm" + if not force and destination.is_file(): + with destination.open("rb") as cached: + if cached.read(8) == WASM_HEADER: + return destination + directory.mkdir(parents=True, exist_ok=True) + # Only copy the selected member; never extract archive paths onto the host. + with tempfile.TemporaryDirectory(dir=directory) as temporary: + archive = Path(temporary) / "release.zip" + digest = hashlib.sha256() + size = 0 + with self._request(asset["browser_download_url"]) as response, archive.open("wb") as out: + while True: + block = response.read(1024 * 1024) + if not block: + break + size += len(block) + if size > MAX_ARCHIVE: + raise ValueError("WASI archive exceeds 128 MiB") + digest.update(block) + out.write(block) + expected = asset.get("digest") + if expected and expected.startswith("sha256:"): + if expected != "sha256:" + digest.hexdigest(): + raise ValueError("WASI archive SHA-256 mismatch") + with zipfile.ZipFile(archive) as package: + entries = [entry for entry in package.infolist() + if PurePosixPath(entry.filename).name == "radare2.wasm"] + if len(entries) != 1: + raise ValueError("Expected exactly one radare2.wasm in the API archive") + if entries[0].file_size > MAX_MODULE: + raise ValueError("WASM module exceeds 256 MiB") + module = package.read(entries[0]) + if not module.startswith(WASM_HEADER): + raise ValueError("The release archive does not contain a WASM module") + output = Path(temporary) / "radare2.wasm" + output.write_bytes(module) + os.replace(output, destination) + return destination diff --git a/python-wasm/tests/test_releases.py b/python-wasm/tests/test_releases.py new file mode 100644 index 0000000..a47dbb7 --- /dev/null +++ b/python-wasm/tests/test_releases.py @@ -0,0 +1,92 @@ +"""Offline release/download behavior, including pagination and unsafe archives.""" +import hashlib +import io +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from r2pipe_wasm.releases import API, DownloadManager, WASM_HEADER + + +def release(tag="6.2.2", *, api=True, prerelease=False): + return {"tag_name": tag, "draft": False, "prerelease": prerelease, + "assets": [{"id": 42, "name": f"radare2-{tag}-wasi-api.zip", + "browser_download_url": "https://github.com/asset"}] if api else []} + + +def archive(entries): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as package: + for name, data in entries: + package.writestr(name, data) + return buffer.getvalue() + + +class ReleasesTest(unittest.TestCase): + def test_pagination_and_selection(self): + manager = DownloadManager() + first = [release("new", api=False)] * 98 + [release(prerelease=True), + dict(release(), draft=True)] + with patch.object(manager, "_json", side_effect=[first, [release()]]) as request: + self.assertEqual(manager.select()["tag_name"], "6.2.2") + self.assertEqual(request.call_args.args[0], API + "?per_page=100&page=2") + with patch.object(manager, "_json", return_value=[release(prerelease=True), release(api=False)]): + self.assertEqual(len(list(manager.releases())), 2) + with self.assertRaisesRegex(ValueError, "No stable"): + manager.select() + + def test_exact_tag(self): + manager = DownloadManager() + with patch.object(manager, "_json", return_value=release(prerelease=True)) as request: + manager.select("test/tag") + self.assertTrue(request.call_args.args[0].endswith("/tags/test%2Ftag")) + with patch.object(manager, "_json", return_value=release(api=False)): + with self.assertRaisesRegex(ValueError, "no wasi-api"): + manager.select("6.2.4") + + def test_cache_and_digest(self): + data = archive([("../../radare2.wasm", WASM_HEADER + b"fixture")]) + selected = release() + selected["assets"][0]["digest"] = "sha256:" + hashlib.sha256(data).hexdigest() + with tempfile.TemporaryDirectory() as temporary: + manager = DownloadManager(temporary) + with patch.object(manager, "select", return_value=selected), patch.object( + manager, "_request", side_effect=lambda *_a, **_k: io.BytesIO(data)) as request: + path = manager.download() + self.assertEqual(path.read_bytes(), WASM_HEADER + b"fixture") + self.assertEqual(manager.download(), path) + self.assertEqual(request.call_count, 1) + self.assertEqual(list(Path(temporary).rglob("*.wasm")), [path]) + selected["assets"][0]["digest"] = "sha256:incorrect" + with self.assertRaisesRegex(ValueError, "SHA-256"): + manager.download(force=True) + self.assertEqual(path.read_bytes(), WASM_HEADER + b"fixture") + + def test_bad_archives(self): + cases = [([], "exactly one"), + ([("a/radare2.wasm", WASM_HEADER), ("b/radare2.wasm", WASM_HEADER)], "exactly one"), + ([("radare2.wasm", b"not wasm")], "WASM module")] + with tempfile.TemporaryDirectory() as temporary: + manager = DownloadManager(temporary) + for entries, message in cases: + with self.subTest(message=message), patch.object(manager, "select", return_value=release()), \ + patch.object(manager, "_request", return_value=io.BytesIO(archive(entries))): + with self.assertRaisesRegex(ValueError, message): + manager.download() + self.assertFalse(list(Path(temporary).rglob("*.wasm"))) + + def test_token_only_on_api(self): + manager = DownloadManager(token="secret") + with patch("r2pipe_wasm.releases.urlopen") as request: + manager._request(API, api=True) + self.assertEqual(request.call_args.args[0].get_header("Authorization"), "Bearer secret") + manager._request("https://github.com/asset") + self.assertIsNone(request.call_args.args[0].get_header("Authorization")) + + +if __name__ == "__main__": + unittest.main() diff --git a/python-wasm/tests/test_runtime.py b/python-wasm/tests/test_runtime.py new file mode 100644 index 0000000..dc2e5c4 --- /dev/null +++ b/python-wasm/tests/test_runtime.py @@ -0,0 +1,114 @@ +"""Exercise the actual Wasmer engine, not a mocked memory/exports interface.""" +import os +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import r2pipe_wasm + +try: + from wasmer import wat2wasm + import wasmer_compiler_cranelift + HAVE_WASMER = True +except ImportError: + HAVE_WASMER = False + +FIXTURE = Path(__file__).resolve().parents[2] / "typescript/tests/fixtures/wasm-api.wasm" + + +@unittest.skipUnless(HAVE_WASMER, "Wasmer native SDK not installed") +class RuntimeTest(unittest.TestCase): + def test_commands_growth_json_and_cleanup(self): + r2 = r2pipe_wasm.open(wasm=FIXTURE) + exports = r2._exports + try: + self.assertEqual(r2.cmd("hello ☃"), "hello ☃") + self.assertEqual(r2.cmd("grow memory"), "grow memory") + long_output = "g" + "hé" * 4000 + self.assertEqual(r2.cmd(long_output), long_output) + self.assertEqual(r2.cmdj("json"), {"offset": 18446744073709551615}) + with self.assertRaises(ValueError): + r2.cmd("embedded\0NUL") + with self.assertRaisesRegex(RuntimeError, "null string"): + r2.cmd("!error") + with self.assertRaises(ValueError): + r2.cmdj("invalid JSON") + self.assertEqual(exports.allocated_strings(), exports.freed_strings()) + finally: + r2.quit() + r2.quit() + self.assertEqual(exports.freed_cores(), 1) + with self.assertRaisesRegex(RuntimeError, "closed"): + r2.cmd("closed") + + def test_open_failure_frees_core(self): + # Observe the instance created by the real runtime during failed construction. + from unittest.mock import patch + from wasmer import Instance + instances = [] + def instantiate(*args): + instance = Instance(*args) + instances.append(instance) + return instance + with patch("wasmer.Instance", side_effect=instantiate): + with self.assertRaises(OSError): + r2pipe_wasm.open("!missing", wasm=FIXTURE) + exports = instances[0].exports + self.assertEqual(exports.freed_cores(), 1) + self.assertEqual(exports.allocated_strings(), exports.freed_strings()) + + def test_bad_module_and_host_import(self): + with tempfile.TemporaryDirectory() as temporary: + wasm = Path(temporary) / "bad.wasm" + wasm.write_bytes(wat2wasm('(module)')) + with self.assertRaisesRegex(ValueError, "Missing WASM exports"): + r2pipe_wasm.open(wasm=wasm) + wasm.write_bytes(wat2wasm('(module (import "r2" "http_get" (func)))')) + with self.assertRaisesRegex(ValueError, "Unsupported WASM import"): + r2pipe_wasm.open(wasm=wasm) + + def test_socket_import_is_denied(self): + # Add WASI socket import to a small reactor. The callback must return ENOTSUP. + wat = '''(module + (import "wasi_snapshot_preview1" "sock_accept" (func $sock (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "malloc") (param i32) (result i32) i32.const 1024) + (func (export "free") (param i32)) + (func (export "r_core_new") (result i32) i32.const 1) + (func (export "r_core_free") (param i32)) + (func (export "r_core_cmd_str") (param i32 i32) (result i32) i32.const 2048) + (func (export "r_core_file_open") (param i32 i32 i32 i64) (result i32) i32.const 1) + (func (export "r_core_bin_load") (param i32 i32 i64) (result i32) i32.const 1) + (func (export "probe") (result i32) i32.const 0 i32.const 0 i32.const 0 call $sock))''' + with tempfile.TemporaryDirectory() as temporary: + wasm = Path(temporary) / "sockets.wasm" + wasm.write_bytes(wat2wasm(wat)) + with r2pipe_wasm.open(wasm=wasm) as r2: + self.assertEqual(r2._exports.probe(), 58) + + +@unittest.skipUnless(HAVE_WASMER and os.environ.get("R2PIPE_WASM"), "Set R2PIPE_WASM for real radare2") +class Radare2Test(unittest.TestCase): + def test_shared_files_and_isolation(self): + with tempfile.TemporaryDirectory() as temporary: + shared = Path(temporary) + sample = shared / "sample.bin" + sample.write_bytes(bytes.fromhex("9090c3")) + wasm = os.environ["R2PIPE_WASM"] + with r2pipe_wasm.open("/work/sample.bin", wasm=wasm, + preopens={"/work": shared}) as r2: + self.assertEqual(r2.cmdj("pxj 3"), [144, 144, 195]) + r2.cmd("e asm.arch=x86; e asm.bits=64") + self.assertIn("nop", r2.cmd("pd 3")) + r2.cmd("px 3 > /work/hexdump.txt") + self.assertIn("9090", (shared / "hexdump.txt").read_text()) + r2.cmd("!touch /work/spawned") + self.assertFalse((shared / "spawned").exists()) + with self.assertRaises(OSError): + r2pipe_wasm.open(str(sample), wasm=wasm) + + +if __name__ == "__main__": + unittest.main()