mirror of
https://github.com/radareorg/radare2-r2pipe.git
synced 2026-11-07 06:35:58 -10:00
Add Python Wasmer backend and WASI release download manager
This commit is contained in:
1 parent
828bd5c18e
commit
aaa7959995
10 files changed
+700
No files matched your search
@@ -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
|
||||
@@ -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++
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user