use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::Value;
use wasmi::{Engine, Linker, Module, Store, TypedFunc};
use crate::core::extension_registry::{Compressor, ExtensionRegistry, truncate_to_budget};
use crate::core::providers::{ContextProvider, ProviderItem, ProviderParams, ProviderResult};
pub const ENTRY_COMPRESS: &str = "lctx_compress";
pub const ENTRY_PROVIDER_FETCH: &str = "lctx_provider_fetch";
#[derive(Debug, thiserror::Error)]
pub enum WasmError {
#[error("failed to read wasm at {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("wasm module error: {0}")]
Module(String),
#[error("wasm runtime error: {0}")]
Runtime(String),
}
#[derive(Clone)]
pub struct WasmModule {
engine: Engine,
module: Module,
}
impl WasmModule {
pub fn from_wasm(bytes: &[u8]) -> Result<Self, WasmError> {
let engine = Engine::default();
let module = Module::new(&engine, bytes).map_err(|e| WasmError::Module(e.to_string()))?;
Ok(Self { engine, module })
}
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, WasmError> {
let path = path.as_ref();
let bytes = std::fs::read(path).map_err(|source| WasmError::Io {
path: path.to_path_buf(),
source,
})?;
Self::from_wasm(&bytes)
}
pub fn call_bytes(&self, entry: &str, input: &[u8], arg: i32) -> Result<Vec<u8>, WasmError> {
let mut store = Store::new(&self.engine, ());
let linker = <Linker<()>>::new(&self.engine);
let instance = linker
.instantiate_and_start(&mut store, &self.module)
.map_err(|e| WasmError::Runtime(e.to_string()))?;
let memory = instance
.get_memory(&store, "memory")
.ok_or_else(|| WasmError::Runtime("guest does not export `memory`".into()))?;
let alloc: TypedFunc<i32, i32> = instance
.get_typed_func(&store, "alloc")
.map_err(|e| WasmError::Runtime(format!("missing `alloc`: {e}")))?;
let func: TypedFunc<(i32, i32, i32), i64> = instance
.get_typed_func(&store, entry)
.map_err(|e| WasmError::Runtime(format!("missing entrypoint `{entry}`: {e}")))?;
let in_len = i32::try_from(input.len())
.map_err(|_| WasmError::Runtime("input exceeds 2GiB".into()))?;
let in_ptr = alloc
.call(&mut store, in_len)
.map_err(|e| WasmError::Runtime(format!("alloc trapped: {e}")))?;
memory
.write(&mut store, in_ptr as usize, input)
.map_err(|e| WasmError::Runtime(format!("failed to write input: {e}")))?;
let packed = func
.call(&mut store, (in_ptr, in_len, arg))
.map_err(|e| WasmError::Runtime(format!("entrypoint trapped: {e}")))?;
let out_ptr = ((packed >> 32) & 0xffff_ffff) as u32 as usize;
let out_len = (packed & 0xffff_ffff) as u32 as usize;
let mem = memory.data(&store);
let end = out_ptr
.checked_add(out_len)
.ok_or_else(|| WasmError::Runtime("output region overflows".into()))?;
if end > mem.len() {
return Err(WasmError::Runtime(format!(
"output region [{out_ptr}, {end}) out of bounds (mem {})",
mem.len()
)));
}
Ok(mem[out_ptr..end].to_vec())
}
}
pub struct WasmCompressor {
name: String,
module: WasmModule,
}
impl WasmCompressor {
#[must_use]
pub fn new(name: impl Into<String>, module: WasmModule) -> Self {
Self {
name: name.into(),
module,
}
}
pub fn load(path: impl AsRef<Path>, name: impl Into<String>) -> Result<Self, WasmError> {
Ok(Self::new(name, WasmModule::from_path(path)?))
}
}
impl Compressor for WasmCompressor {
fn name(&self) -> &str {
&self.name
}
fn compress(&self, input: &str, budget: Option<usize>) -> String {
let arg = budget.and_then(|b| i32::try_from(b).ok()).unwrap_or(-1);
let out = match self
.module
.call_bytes(ENTRY_COMPRESS, input.as_bytes(), arg)
{
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(_) => input.to_string(),
};
truncate_to_budget(out, budget)
}
}
pub fn register_compressors_from_dir(
reg: &mut ExtensionRegistry,
dir: impl AsRef<Path>,
) -> Vec<String> {
let mut registered = Vec::new();
let Ok(entries) = std::fs::read_dir(dir.as_ref()) else {
return registered;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Ok(c) = WasmCompressor::load(&path, stem) {
reg.register_compressor(Arc::new(c));
registered.push(stem.to_string());
}
}
registered.sort();
registered
}
pub struct WasmProvider {
id: &'static str,
display: &'static str,
actions: Vec<&'static str>,
module: WasmModule,
}
impl WasmProvider {
#[must_use]
pub fn new(
id: impl Into<String>,
display: impl Into<String>,
actions: Vec<String>,
module: WasmModule,
) -> Self {
let id: &'static str = Box::leak(id.into().into_boxed_str());
let display: &'static str = Box::leak(display.into().into_boxed_str());
let actions: Vec<&'static str> = actions
.into_iter()
.map(|a| &*Box::leak(a.into_boxed_str()))
.collect();
Self {
id,
display,
actions,
module,
}
}
pub fn load(
path: impl AsRef<Path>,
id: impl Into<String>,
display: impl Into<String>,
actions: Vec<String>,
) -> Result<Self, WasmError> {
Ok(Self::new(
id,
display,
actions,
WasmModule::from_path(path)?,
))
}
fn parse_result(&self, action: &str, bytes: &[u8]) -> Result<ProviderResult, String> {
let v: Value = serde_json::from_slice(bytes)
.map_err(|e| format!("guest returned invalid JSON: {e}"))?;
let resource_type = v
.get("resource_type")
.and_then(Value::as_str)
.unwrap_or(action)
.to_string();
let truncated = v.get("truncated").and_then(Value::as_bool).unwrap_or(false);
let total_count = v
.get("total_count")
.and_then(Value::as_u64)
.map(|n| n as usize);
let items = v
.get("items")
.and_then(Value::as_array)
.map(|arr| arr.iter().map(item_from_json).collect())
.unwrap_or_default();
Ok(ProviderResult {
provider: self.id.to_string(),
resource_type,
items,
total_count,
truncated,
})
}
}
fn item_from_json(v: &Value) -> ProviderItem {
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
ProviderItem {
id: s("id").unwrap_or_default(),
title: s("title").unwrap_or_default(),
state: s("state"),
author: s("author"),
created_at: s("created_at"),
updated_at: s("updated_at"),
url: s("url"),
labels: v
.get("labels")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
body: s("body"),
claims: Vec::new(),
}
}
#[derive(Debug, serde::Deserialize)]
struct ProviderSidecar {
id: Option<String>,
display: Option<String>,
#[serde(default)]
actions: Vec<String>,
}
pub fn register_providers_from_dir(
reg: &crate::core::providers::ProviderRegistry,
dir: impl AsRef<Path>,
) -> Vec<String> {
let mut registered = Vec::new();
let Ok(entries) = std::fs::read_dir(dir.as_ref()) else {
return registered;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let sidecar: ProviderSidecar =
std::fs::read_to_string(path.with_extension("provider.json"))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or(ProviderSidecar {
id: None,
display: None,
actions: Vec::new(),
});
let id = sidecar.id.unwrap_or_else(|| stem.to_string());
let display = sidecar.display.unwrap_or_else(|| id.clone());
let actions = if sidecar.actions.is_empty() {
vec!["fetch".to_string()]
} else {
sidecar.actions
};
if let Ok(p) = WasmProvider::load(&path, id.clone(), display, actions) {
reg.register(Arc::new(p));
registered.push(id);
}
}
registered.sort();
registered
}
impl ContextProvider for WasmProvider {
fn id(&self) -> &'static str {
self.id
}
fn display_name(&self) -> &'static str {
self.display
}
fn supported_actions(&self) -> &[&str] {
&self.actions
}
fn execute(&self, action: &str, params: &ProviderParams) -> Result<ProviderResult, String> {
let request = serde_json::json!({
"action": action,
"params": {
"project": params.project,
"state": params.state,
"limit": params.limit,
"query": params.query,
"id": params.id,
}
});
let req_bytes = serde_json::to_vec(&request).map_err(|e| e.to_string())?;
let out = self
.module
.call_bytes(ENTRY_PROVIDER_FETCH, &req_bytes, 0)
.map_err(|e| e.to_string())?;
self.parse_result(action, &out)
}
fn requires_auth(&self) -> bool {
false
}
fn is_available(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
const GUEST_WAT: &str = r#"
(module
(memory (export "memory") 1)
(global $heap (mut i32) (i32.const 1024))
(data (i32.const 0) "{\"provider\":\"wasm\",\"resource_type\":\"items\",\"items\":[{\"id\":\"1\",\"title\":\"hi\"}],\"truncated\":false}")
(func (export "alloc") (param $n i32) (result i32)
(local $p i32)
(local.set $p (global.get $heap))
(global.set $heap (i32.add (global.get $heap) (local.get $n)))
(local.get $p))
(func (export "lctx_compress") (param $ptr i32) (param $len i32) (param $arg i32) (result i64)
(i64.or
(i64.shl (i64.extend_i32_u (local.get $ptr)) (i64.const 32))
(i64.extend_i32_u (local.get $len))))
(func (export "lctx_provider_fetch") (param $ptr i32) (param $len i32) (param $arg i32) (result i64)
(i64.or
(i64.shl (i64.extend_i32_u (i32.const 0)) (i64.const 32))
(i64.const 95))))
"#;
fn guest() -> WasmModule {
let bytes = wat::parse_str(GUEST_WAT).expect("valid wat");
WasmModule::from_wasm(&bytes).expect("module compiles")
}
#[test]
fn host_round_trips_bytes_through_memory() {
let m = guest();
let out = m.call_bytes(ENTRY_COMPRESS, b"hello world", -1).unwrap();
assert_eq!(out, b"hello world");
}
#[test]
fn wasm_compressor_implements_trait_and_honors_budget() {
let c = WasmCompressor::new("echo", guest());
assert_eq!(c.name(), "echo");
assert_eq!(c.compress("abcdefgh", Some(5)), "abcde");
assert_eq!(c.compress("abc", None), "abc");
}
#[test]
fn wasm_compressor_is_deterministic_and_idempotent() {
let c = WasmCompressor::new("echo", guest());
let once = c.compress("repeatable", None);
let twice = c.compress("repeatable", None);
assert_eq!(once, twice);
assert_eq!(c.compress(&once, None), once);
}
#[test]
fn wasm_compressor_registers_as_first_class_extension() {
let mut reg = ExtensionRegistry::with_builtins();
reg.register_compressor(Arc::new(WasmCompressor::new("wecho", guest())));
assert!(reg.compressor_names().contains(&"wecho".to_string()));
let c = reg.compressor("wecho").unwrap();
assert_eq!(c.compress("xyz", None), "xyz");
}
#[test]
fn wasm_provider_maps_guest_json_to_result() {
let p = WasmProvider::new(
"wasm".to_string(),
"WASM Provider".to_string(),
vec!["items".to_string()],
guest(),
);
assert_eq!(p.id(), "wasm");
assert_eq!(p.supported_actions(), &["items"]);
assert!(!p.requires_auth());
assert!(p.is_available());
let res = p.execute("items", &ProviderParams::default()).unwrap();
assert_eq!(res.provider, "wasm");
assert_eq!(res.resource_type, "items");
assert_eq!(res.items.len(), 1);
assert_eq!(res.items[0].id, "1");
assert_eq!(res.items[0].title, "hi");
assert!(!res.truncated);
}
#[test]
fn missing_entrypoint_is_reported_not_panicked() {
let m = guest();
let err = m.call_bytes("nope", b"x", 0).unwrap_err();
assert!(matches!(err, WasmError::Runtime(_)));
}
#[test]
fn invalid_module_bytes_error() {
let res = WasmModule::from_wasm(b"not wasm");
assert!(matches!(res, Err(WasmError::Module(_))));
}
}