Add Python Wasmer backend and WASI release download manager

This commit is contained in:
pancake committed 2026-10-18 18:52:08 +02:00
1 parent 828bd5c18e
commit aaa7959995
10 files changed
+700

No files matched your search

+38
View File
@@ -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
+1
View File
@@ -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++
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.egg-info/
build/
dist/
+21
View File
@@ -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.
+137
View File
@@ -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.
+15
View File
@@ -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"]
+161
View File
@@ -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)
+117
View File
@@ -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
+92
View File
@@ -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()
+114
View File
@@ -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()