Einführung
Erweiterbarkeit ist ein altes Versprechen der Softwaretechnik – und zugleich ihre größte Sicherheitslücke.
Hier kommt WebAssembly (WASM) ins Spiel – ursprünglich für Browser entwickelt, heute eine universelle Plattform für sichere, portable Module. Rust ergänzt dieses Konzept perfekt: präzise, schnell und speichersicher.
Was ist WebAssembly?
WebAssembly (WASM) ist ein plattformunabhängiges Binärformat für eine kompakte virtuelle Maschine.
Ein .wasm-Artefakt läuft überall dort, wo eine Engine (z. B. wasmtime, wasmer) vorhanden ist – unabhängig vom Betriebssystem.
- Portabel: Ein Artefakt für viele Plattformen (Server, Edge, Desktop, Browser).
- Schnell: Durch JIT- oder AOT-Kompilierung oft nahe an nativer Performance.
- Sicher: Keine impliziten Systemzugriffe – standardmäßig keine Datei-, Netzwerk- oder Prozessrechte.
WASM hat seinen Ursprung im Web, wird heute aber zunehmend serverseitig, in Edge-Runtimes oder als Plugin-Format genutzt.
- Engine: Führt das WASM-Modul aus (z. B. wasmtime, wasmer).
- WASI: WebAssembly System Interface – standardisierte APIs für Dateien, Zeit, Umgebungsvariablen usw.
- Component Model / WIT: Ein modernes Schnittstellenmodell für modulare, sprachübergreifende WASM-Komponenten.
Warum WASM als Plugin-Format?
Ein Plugin-System soll neuen Code laden können – ohne Risiko für Stabilität oder Sicherheit.
- Isolation: Jedes Plugin läuft in einer Sandbox mit eigenem Speicher.
- Kontrollierte Schnittstellen: Nur explizit freigegebene Funktionen sind sichtbar.
- Mehrsprachigkeit: Rust, Zig, C/C++, TinyGo oder AssemblyScript – alles kann nach WASM kompilieren.
- Portabilität: Plugins laufen auf Linux, macOS, Windows oder am Edge.
- Performance: Für CPU-intensive Aufgaben nahezu nativ, IO läuft kontrolliert über den Host.
Sandboxing & Speicher – wie Sicherheit entsteht
- Default-deny: Ein WASM-Modul darf standardmäßig nichts – der Host muss explizit Rechte vergeben.
- Linear Memory: Ein Modul verwaltet seinen Speicher selbst; Pointer in den Host sind ausgeschlossen.
- Imports/Exports: Kommunikation erfolgt nur über klar definierte Funktionen und Datentransfers.
/data (WASI), das nur Zugriff auf genau diesen Ordner erlaubt.
Rust als Host – warum das passt
- Sicherheit: Keine „Undefined Behavior“-Überraschungen.
- Performance: Geringer Overhead, ideal für die Einbettung einer Engine.
- Ökosystem: Crates wie
wasmtime,wasmeroderextismsind ausgereift. - Transparenz: Policies und Schnittstellen sind explizit und leicht überprüfbar.
Beispielprojekt: Rust-Host + WASM-Plugin (Addition)
Ein einfaches, aber vollständiges Beispiel:
- Plugin
plugin_add: exportiertadd(a, b) -> i32 - Host
host: lädt das Plugin und ruftaddauf
Projektstruktur:
wasm-plugin-mvp/
├─ host/
└─ plugin_add/
Plugin plugin_add:
plugin_add/Cargo.toml
[package]
name = "plugin_add"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
plugin_add/src/lib.rs
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
Build (WASM):
cd plugin_add
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
# → target/wasm32-unknown-unknown/release/plugin_add.wasm
Host host
host/Cargo.toml
[package]
name = "host"
version = "0.1.0"
edition = "2021"
[dependencies]
wasmtime = "26"
anyhow = "1"
host/src/main.rs
use anyhow::Result;
use wasmtime::{Engine, Module, Store, Instance, TypedFunc};
fn main() -> Result<()> {
let wasm_path = "../plugin_add/target/wasm32-unknown-unknown/release/plugin_add.wasm";
let engine = Engine::default();
let module = Module::from_file(&engine, wasm_path)?;
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
let add: TypedFunc<(i32, i32), i32> = instance.get_typed_func(&mut store, "add")?;
let result = add.call(&mut store, (21, 21))?;
println!("add(21, 21) = {}", result);
Ok(())
}
Ausführen:
cd host
cargo run
# add(21, 21) = 42
rustup target add wasm32-unknown-unknown- Plugin im Release-Modus bauen
- Host auf das richtige
.wasm-Artefakt verweisen
Fortgeschritten: Datei-Zugriff – kontrolliert aus der Sandbox
Standard-WASM kann keine Dateien öffnen. Zugriff ist nur über explizite Schnittstellen möglich. Es gibt zwei Wege, diesen Zugriff gezielt zu erlauben:
- Custom-Host-API: Der Host stellt eine eigene Funktion bereit (z. B.
append_result(path, value)). - WASI: Der Host öffnet ein Verzeichnis vorab („preopened directory“) und erlaubt Zugriff nur dort.
Im Folgenden werden beide Varianten gezeigt.
Variante A: Custom-API (Host-Funktion)
Hier implementiert der Host selbst, wie und wohin geschrieben wird – inklusive Pfadvalidierung.
Plugin plugin_append:
extern "C" {
// Importierte Host-Funktion: (path_ptr, path_len, value) -> status
fn append_result(ptr: i32, len: i32, value: i32) -> i32;
}
#[no_mangle]
pub extern "C" fn add_and_append(path_ptr: i32, path_len: i32, a: i32, b: i32) -> i32 {
let sum = a + b;
unsafe { append_result(path_ptr, path_len, sum); }
sum
}
Host (mit Pfad-Policy):
use anyhow::Result;
use wasmtime::{Engine, Module, Store, Linker, Caller};
use std::fs::OpenOptions;
use std::io::Write;
fn main() -> Result<()> {
let engine = Engine::default();
let module = Module::from_file(&engine, "../plugin_append/target/wasm32-unknown-unknown/release/plugin_append.wasm")?;
let mut store = Store::new(&engine, ());
let mut linker = Linker::new(&engine);
linker.func_wrap("env", "append_result", move |mut caller: Caller<'_, ()>, ptr: i32, len: i32, value: i32| {
let mem = caller.get_export("memory").unwrap().into_memory().unwrap();
let data = mem.data(&caller);
let start = ptr as usize;
let end = start + len as usize;
if end > data.len() { return 1; }
let path = std::str::from_utf8(&data[start..end]).unwrap_or("");
if !path.starts_with("./data/") {
eprintln!("Verbotener Pfad: {}", path);
return 1;
}
let _ = std::fs::create_dir_all("./data");
match OpenOptions::new().create(true).append(true).open(path) {
Ok(mut f) => { let _ = writeln!(f, "{}", value); 0 }
Err(e) => { eprintln!("IO-Fehler: {e}"); 1 }
}
})?;
let instance = linker.instantiate(&mut store, &module)?;
let memory = instance.get_memory(&mut store, "memory").expect("memory export");
let path = "./data/result.txt";
let offset = 1024usize;
memory.data_mut(&mut store)[offset..offset + path.len()].copy_from_slice(path.as_bytes());
let func = instance.get_typed_func::<(i32, i32, i32, i32), i32>(&mut store, "add_and_append")?;
let res = func.call(&mut store, (offset as i32, path.len() as i32, 7, 11))?;
println!("sum={}, written→{}", res, path);
Ok(())
}
- Maximale Feingranularität: Der Host kontrolliert Pfade, Inhalte und Quoten.
- Kein WASI nötig – funktioniert mit
wasm32-unknown-unknown. - Ideal, wenn Sicherheit und Auditierbarkeit höchste Priorität haben.
Variante B: WASI mit preopened Directory
Hier kann das Plugin über die Standardbibliothek schreiben – aber nur in ein vorab freigegebenes Verzeichnis.
Plugin plugin_append_wasi:
use std::fs::OpenOptions;
use std::io::Write;
#[no_mangle]
pub extern "C" fn add_and_append_wasi(a: i32, b: i32) -> i32 {
let sum = a + b;
let path = "/data/result.txt";
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "{}", sum);
}
sum
}
Host mit WASI-Kontext:
use anyhow::Result;
use wasmtime::{Engine, Linker, Module, Store};
use wasmtime_wasi::{preview1, DirPerms, FilePerms, WasiCtxBuilder};
fn main() -> Result<()> {
let engine = Engine::default();
let module = Module::from_file(
&engine,
"../plugin_append_wasi/target/wasm32-wasip1/release/plugin_append_wasi.wasm",
)?;
let wasi_p1 = WasiCtxBuilder::new()
.inherit_stdio()
.preopened_dir("./data", "/data", DirPerms::all(), FilePerms::all())?
.build_p1();
let mut store = Store::new(&engine, wasi_p1);
let mut linker = Linker::new(&engine);
preview1::add_to_linker_sync(&mut linker, |cx| cx)?;
let instance = linker.instantiate(&mut store, &module)?;
let run = instance.get_typed_func::<(i32, i32), i32>(&mut store, "add_and_append_wasi")?;
let result = run.call(&mut store, (5, 10))?;
println!("sum={}, written→./data/result.txt", result);
Ok(())
}
Ergänzung der Dependencies:
[package]
name = "host"
version = "0.1.0"
edition = "2021"
[dependencies]
wasmtime = "26"
anyhow = "1"
wasmtime-wasi = "26"
cap-std = "3"
Abweichender Build des Plugins:
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
- Plugin-Autoren nutzen normale
std::fs-APIs. - Sehr entwicklerfreundlich, klar begrenzt durch preopened directories.
- Ideal für vertrauenswürdige Plugin-Entwickler, die in einem kontrollierten Umfeld arbeiten.
Performance – worauf es ankommt
- Batching: Wenige, größere Aufrufe über die Host/Guest-Grenze.
- Release-Builds: Sowohl Host als auch Plugins in
--releasekompilieren. - Effiziente Datenformate: Binär (z. B. Flatbuffers, Protobuf) statt vieler kleiner Strings.
- AOT (optional): Mit Wasmtime vorcompilierte Module starten deutlich schneller.
Fazit
WASM liefert die Sandbox, Rust den präzisen Host. Gemeinsam schaffen sie eine vertrauenswürdige Modularität: Plugins lassen sich sicher laden, laufen portabel und greifen nur auf freigegebene Ressourcen zu.
Das Beispiel mit Addition und Datei-Append zeigt den Weg:
- minimal starten,
- Schnittstellen klar definieren,
- Erweiterbarkeit gezielt erlauben.
So entstehen Plattformen, die offen wirken – und sicher bleiben.
„Nicht Vertrauen macht Systeme stabil, sondern das Fehlen von impliziten Möglichkeiten.“
Bildnachweis: Das Rust-Logo ist eine eingetragene Marke der Rust Foundation. Verwendung im Rahmen redaktioneller Berichterstattung gemäß den Markenrichtlinien der Rust Foundation.