use std::path::{Path, PathBuf};
use std::sync::Arc;
use chrono::{Local, SecondsFormat};
use diaryx_core::fs::AsyncFileSystem;
use diaryx_core::plugin::permissions::PermissionType;
use extism::{CurrentPlugin, Error as ExtismError, UserData, Val, ValType};
use crate::permission_checker::DenyAllPermissionChecker;
pub trait PluginStorage: Send + Sync {
fn get(&self, key: &str) -> Option<Vec<u8>>;
fn set(&self, key: &str, data: &[u8]);
fn delete(&self, key: &str);
}
pub trait PluginSecretStore: Send + Sync {
fn get(&self, key: &str) -> Option<String>;
fn set(&self, key: &str, value: &str);
fn delete(&self, key: &str);
}
pub trait EventEmitter: Send + Sync {
fn emit(&self, event_json: &str);
}
pub trait WebSocketBridge: Send + Sync {
fn request(&self, request_json: &str) -> Result<String, String>;
}
pub trait PluginCommandBridge: Send + Sync {
fn call(
&self,
caller_plugin_id: &str,
plugin_id: &str,
command: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, String>;
}
pub trait RuntimeContextProvider: Send + Sync {
fn get_context(&self, plugin_id: &str) -> serde_json::Value;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct NamespaceObjectMeta {
#[serde(default)]
pub namespace_id: Option<String>,
pub key: String,
#[serde(default)]
pub r2_key: Option<String>,
#[serde(default)]
pub audience: Option<String>,
#[serde(default)]
pub mime_type: Option<String>,
#[serde(default)]
pub size_bytes: Option<u64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub content_hash: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct NamespaceEntry {
pub id: String,
pub owner_user_id: String,
pub created_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
pub trait NamespaceProvider: Send + Sync {
fn create_namespace(
&self,
metadata: Option<&serde_json::Value>,
) -> Result<NamespaceEntry, String>;
fn put_object(
&self,
ns_id: &str,
key: &str,
bytes: &[u8],
mime_type: &str,
audience: Option<&str>,
) -> Result<(), String>;
fn get_object(&self, ns_id: &str, key: &str) -> Result<Vec<u8>, String>;
fn delete_object(&self, ns_id: &str, key: &str) -> Result<(), String>;
fn list_objects(
&self,
ns_id: &str,
prefix: Option<&str>,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Vec<NamespaceObjectMeta>, String>;
fn sync_audience(&self, ns_id: &str, audience: &str, access: &str) -> Result<(), String>;
fn send_audience_email(
&self,
ns_id: &str,
audience: &str,
subject: &str,
reply_to: Option<&str>,
) -> Result<serde_json::Value, String>;
fn list_namespaces(&self) -> Result<Vec<NamespaceEntry>, String>;
}
pub struct NoopStorage;
impl PluginStorage for NoopStorage {
fn get(&self, _key: &str) -> Option<Vec<u8>> {
None
}
fn set(&self, _key: &str, _data: &[u8]) {}
fn delete(&self, _key: &str) {}
}
pub struct NoopSecretStore;
impl PluginSecretStore for NoopSecretStore {
fn get(&self, _key: &str) -> Option<String> {
None
}
fn set(&self, _key: &str, _value: &str) {}
fn delete(&self, _key: &str) {}
}
fn sanitize_storage_key(key: &str) -> String {
key.chars()
.map(|c| {
if c == '/' || c == '\\' || c == ':' {
'_'
} else {
c
}
})
.collect()
}
pub struct FilePluginStorage {
base_dir: PathBuf,
}
impl FilePluginStorage {
pub fn new(base_dir: PathBuf) -> Self {
let _ = std::fs::create_dir_all(&base_dir);
Self { base_dir }
}
fn key_to_path(&self, key: &str) -> PathBuf {
self.base_dir
.join(format!("{}.bin", sanitize_storage_key(key)))
}
}
impl PluginStorage for FilePluginStorage {
fn get(&self, key: &str) -> Option<Vec<u8>> {
std::fs::read(self.key_to_path(key)).ok()
}
fn set(&self, key: &str, data: &[u8]) {
let path = self.key_to_path(key);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, data);
}
fn delete(&self, key: &str) {
let _ = std::fs::remove_file(self.key_to_path(key));
}
}
pub struct FilePluginSecretStore {
base_dir: PathBuf,
}
impl FilePluginSecretStore {
pub fn new(base_dir: PathBuf) -> Self {
let _ = std::fs::create_dir_all(&base_dir);
Self { base_dir }
}
fn key_to_path(&self, key: &str) -> PathBuf {
self.base_dir
.join(format!("{}.secret", sanitize_storage_key(key)))
}
}
impl PluginSecretStore for FilePluginSecretStore {
fn get(&self, key: &str) -> Option<String> {
std::fs::read_to_string(self.key_to_path(key)).ok()
}
fn set(&self, key: &str, value: &str) {
let path = self.key_to_path(key);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, value);
}
fn delete(&self, key: &str) {
let _ = std::fs::remove_file(self.key_to_path(key));
}
}
pub trait FileProvider: Send + Sync {
fn get_file(&self, plugin_id: &str, key: &str) -> Option<Vec<u8>>;
}
pub struct NoopFileProvider;
impl FileProvider for NoopFileProvider {
fn get_file(&self, _plugin_id: &str, _key: &str) -> Option<Vec<u8>> {
None
}
}
pub struct MapFileProvider {
files: std::collections::HashMap<String, Vec<u8>>,
}
impl MapFileProvider {
pub fn new(files: std::collections::HashMap<String, Vec<u8>>) -> Self {
Self { files }
}
}
impl FileProvider for MapFileProvider {
fn get_file(&self, _plugin_id: &str, key: &str) -> Option<Vec<u8>> {
self.files.get(key).cloned()
}
}
pub struct NoopEventEmitter;
impl EventEmitter for NoopEventEmitter {
fn emit(&self, _event_json: &str) {}
}
pub struct NoopWebSocketBridge;
impl WebSocketBridge for NoopWebSocketBridge {
fn request(&self, _request_json: &str) -> Result<String, String> {
Ok(String::new())
}
}
pub struct NoopPluginCommandBridge;
impl PluginCommandBridge for NoopPluginCommandBridge {
fn call(
&self,
_caller_plugin_id: &str,
_plugin_id: &str,
_command: &str,
_params: serde_json::Value,
) -> Result<serde_json::Value, String> {
Err("Plugin command bridge is not available".to_string())
}
}
pub struct NoopRuntimeContextProvider;
impl RuntimeContextProvider for NoopRuntimeContextProvider {
fn get_context(&self, _plugin_id: &str) -> serde_json::Value {
serde_json::json!({})
}
}
pub struct NoopNamespaceProvider;
impl NamespaceProvider for NoopNamespaceProvider {
fn create_namespace(
&self,
_metadata: Option<&serde_json::Value>,
) -> Result<NamespaceEntry, String> {
Err("Namespace operations are not available".to_string())
}
fn put_object(
&self,
_ns_id: &str,
_key: &str,
_bytes: &[u8],
_mime_type: &str,
_audience: Option<&str>,
) -> Result<(), String> {
Err("Namespace operations are not available".to_string())
}
fn get_object(&self, _ns_id: &str, _key: &str) -> Result<Vec<u8>, String> {
Err("Namespace operations are not available".to_string())
}
fn delete_object(&self, _ns_id: &str, _key: &str) -> Result<(), String> {
Err("Namespace operations are not available".to_string())
}
fn list_objects(
&self,
_ns_id: &str,
_prefix: Option<&str>,
_limit: Option<u32>,
_offset: Option<u32>,
) -> Result<Vec<NamespaceObjectMeta>, String> {
Err("Namespace operations are not available".to_string())
}
fn sync_audience(&self, _ns_id: &str, _audience: &str, _access: &str) -> Result<(), String> {
Err("Namespace operations are not available".to_string())
}
fn send_audience_email(
&self,
_ns_id: &str,
_audience: &str,
_subject: &str,
_reply_to: Option<&str>,
) -> Result<serde_json::Value, String> {
Err("Namespace operations are not available".to_string())
}
fn list_namespaces(&self) -> Result<Vec<NamespaceEntry>, String> {
Err("Namespace operations are not available".to_string())
}
}
pub trait PermissionChecker: Send + Sync {
fn check_permission(
&self,
plugin_id: &str,
permission_type: PermissionType,
target: &str,
) -> Result<(), String>;
}
pub struct HostContext {
pub fs: Arc<dyn AsyncFileSystem>,
pub storage: Arc<dyn PluginStorage>,
pub secret_store: Arc<dyn PluginSecretStore>,
pub event_emitter: Arc<dyn EventEmitter>,
pub plugin_id: String,
pub plugin_id_locked: bool,
pub permission_checker: Option<Arc<dyn PermissionChecker>>,
pub file_provider: Arc<dyn FileProvider>,
pub ws_bridge: Arc<dyn WebSocketBridge>,
pub plugin_command_bridge: Arc<dyn PluginCommandBridge>,
pub runtime_context_provider: Arc<dyn RuntimeContextProvider>,
pub namespace_provider: Arc<dyn NamespaceProvider>,
pub plugin_command_depth: u32,
pub storage_quota_bytes: u64,
}
pub const DEFAULT_STORAGE_QUOTA_BYTES: u64 = 1024 * 1024;
impl HostContext {
pub fn with_fs(fs: Arc<dyn AsyncFileSystem>) -> Self {
Self {
fs,
storage: Arc::new(NoopStorage),
secret_store: Arc::new(NoopSecretStore),
event_emitter: Arc::new(NoopEventEmitter),
plugin_id: String::new(),
plugin_id_locked: false,
permission_checker: Some(Arc::new(DenyAllPermissionChecker)),
file_provider: Arc::new(NoopFileProvider),
ws_bridge: Arc::new(NoopWebSocketBridge),
plugin_command_bridge: Arc::new(NoopPluginCommandBridge),
runtime_context_provider: Arc::new(NoopRuntimeContextProvider),
namespace_provider: Arc::new(NoopNamespaceProvider),
plugin_command_depth: 0,
storage_quota_bytes: DEFAULT_STORAGE_QUOTA_BYTES,
}
}
fn check_perm(&self, perm: PermissionType, target: &str) -> Result<(), ExtismError> {
if let Some(checker) = &self.permission_checker {
checker
.check_permission(&self.plugin_id, perm, target)
.map_err(|msg| ExtismError::msg(msg))
} else {
Err(ExtismError::msg(
"Permission checker is not configured for this plugin host context",
))
}
}
fn validate_http_headers(
headers: &std::collections::HashMap<String, String>,
) -> Result<(), ExtismError> {
for (name, value) in headers {
if name.contains('\n')
|| name.contains('\r')
|| name.contains('\0')
|| value.contains('\n')
|| value.contains('\r')
|| value.contains('\0')
{
return Err(ExtismError::msg(format!(
"Invalid HTTP header: name or value contains forbidden characters (header: '{name}')"
)));
}
}
Ok(())
}
fn validate_file_path(path: &str) -> Result<String, ExtismError> {
let normalized = path.replace('\\', "/");
for component in normalized.split('/') {
if component == ".." {
return Err(ExtismError::msg(format!(
"Path traversal not allowed: '{path}'"
)));
}
}
Ok(path.to_string())
}
fn storage_key(&self, key: &str) -> String {
if self.plugin_id.is_empty() {
key.to_string()
} else {
format!("{}:{}", self.plugin_id, key)
}
}
fn secret_key(&self, key: &str) -> String {
self.storage_key(key)
}
}
unsafe impl Send for HostContext {}
unsafe impl Sync for HostContext {}
pub fn register_host_functions(
builder: extism::PluginBuilder<'_>,
user_data: UserData<HostContext>,
) -> extism::PluginBuilder<'_> {
builder
.with_function(
"host_log",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_log,
)
.with_function(
"host_read_file",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_read_file,
)
.with_function(
"host_read_binary",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_read_binary,
)
.with_function(
"host_list_files",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_list_files,
)
.with_function(
"host_list_dir",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_list_dir,
)
.with_function(
"host_workspace_file_set",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_workspace_file_set,
)
.with_function(
"host_file_exists",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_file_exists,
)
.with_function(
"host_file_metadata",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_file_metadata,
)
.with_function(
"host_write_file",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_write_file,
)
.with_function(
"host_delete_file",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_delete_file,
)
.with_function(
"host_write_binary",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_write_binary,
)
.with_function(
"host_emit_event",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_emit_event,
)
.with_function(
"host_storage_get",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_storage_get,
)
.with_function(
"host_storage_set",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_storage_set,
)
.with_function(
"host_secret_get",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_secret_get,
)
.with_function(
"host_secret_set",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_secret_set,
)
.with_function(
"host_secret_delete",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_secret_delete,
)
.with_function(
"host_get_timestamp",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_get_timestamp,
)
.with_function(
"host_get_now",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_get_now,
)
.with_function(
"host_http_request",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_http_request,
)
.with_function(
"host_run_wasi_module",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_run_wasi_module,
)
.with_function(
"host_request_file",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_request_file,
)
.with_function(
"host_plugin_command",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_plugin_command,
)
.with_function(
"host_get_runtime_context",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_get_runtime_context,
)
.with_function(
"host_namespace_put_object",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_namespace_put_object,
)
.with_function(
"host_namespace_delete_object",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_namespace_delete_object,
)
.with_function(
"host_namespace_get_object",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_namespace_get_object,
)
.with_function(
"host_namespace_list_objects",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_namespace_list_objects,
)
.with_function(
"host_namespace_list",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_namespace_list,
)
.with_function(
"host_namespace_create",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_namespace_create,
)
.with_function(
"host_namespace_sync_audience",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_namespace_sync_audience,
)
.with_function(
"host_namespace_send_email",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_namespace_send_email,
)
.with_function(
"host_ws_request",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_ws_request,
)
.with_function(
"host_hash_file",
[ValType::I64],
[ValType::I64],
user_data.clone(),
host_hash_file,
)
.with_function(
"host_proxy_request",
[ValType::I64],
[ValType::I64],
user_data,
host_proxy_request,
)
}
fn host_log(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
_user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct LogInput {
level: String,
message: String,
}
let parsed: LogInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_log: invalid input: {e}")))?;
match parsed.level.as_str() {
"error" => log::error!("[extism-plugin] {}", parsed.message),
"warn" => log::warn!("[extism-plugin] {}", parsed.message),
"info" => log::info!("[extism-plugin] {}", parsed.message),
"debug" => log::debug!("[extism-plugin] {}", parsed.message),
_ => log::trace!("[extism-plugin] {}", parsed.message),
}
plugin.memory_set_val(&mut outputs[0], "")?;
Ok(())
}
fn host_read_file(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct ReadInput {
path: String,
}
let parsed: ReadInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_read_file: invalid input: {e}")))?;
let path = HostContext::validate_file_path(&parsed.path)?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_read_file: lock: {e}")))?;
if let Err(e) = ctx.check_perm(PermissionType::ReadFiles, &path) {
let err = serde_json::json!({ "error": e.to_string() }).to_string();
plugin.memory_set_val(&mut outputs[0], err.as_str())?;
return Ok(());
}
match futures_lite::future::block_on(ctx.fs.read_to_string(Path::new(&path))) {
Ok(content) => {
plugin.memory_set_val(&mut outputs[0], content.as_str())?;
}
Err(e) => {
let err = serde_json::json!({ "error": format!("host_read_file: {e}") }).to_string();
plugin.memory_set_val(&mut outputs[0], err.as_str())?;
}
}
Ok(())
}
fn host_read_binary(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
use base64::Engine;
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct ReadInput {
path: String,
}
let parsed: ReadInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_read_binary: invalid input: {e}")))?;
let path = HostContext::validate_file_path(&parsed.path)?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_read_binary: lock: {e}")))?;
ctx.check_perm(PermissionType::ReadFiles, &path)?;
let bytes = futures_lite::future::block_on(ctx.fs.read_binary(Path::new(&path)))
.map_err(|e| ExtismError::msg(format!("host_read_binary: {e}")))?;
let json = serde_json::json!({
"data": base64::engine::general_purpose::STANDARD.encode(&bytes)
})
.to_string();
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
fn host_list_dir(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct ListDirInput {
path: String,
}
let parsed: ListDirInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_list_dir: invalid input: {e}")))?;
let dir_path = HostContext::validate_file_path(&parsed.path)?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_list_dir: lock: {e}")))?;
ctx.check_perm(PermissionType::ReadFiles, &dir_path)?;
let files = futures_lite::future::block_on(ctx.fs.list_files(Path::new(&dir_path)))
.map_err(|e| ExtismError::msg(format!("host_list_dir: {e}")))?;
let file_strings: Vec<String> = files
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
let json = serde_json::to_string(&file_strings)
.map_err(|e| ExtismError::msg(format!("host_list_dir: serialize: {e}")))?;
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
fn host_list_files(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct ListInput {
prefix: String,
}
let parsed: ListInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_list_files: invalid input: {e}")))?;
let prefix = HostContext::validate_file_path(&parsed.prefix)?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_list_files: lock: {e}")))?;
ctx.check_perm(PermissionType::ReadFiles, &prefix)?;
let files = futures_lite::future::block_on(ctx.fs.list_all_files_recursive(Path::new(&prefix)))
.map_err(|e| ExtismError::msg(format!("host_list_files: {e}")))?;
let file_strings: Vec<String> = files
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
let json = serde_json::to_string(&file_strings)
.map_err(|e| ExtismError::msg(format!("host_list_files: serialize: {e}")))?;
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
fn host_workspace_file_set(
plugin: &mut CurrentPlugin,
_inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
fn inner(user_data: &UserData<HostContext>) -> Result<Vec<String>, String> {
let ctx = user_data
.get()
.map_err(|e| format!("host_workspace_file_set: user_data: {e}"))?;
let ctx = ctx
.lock()
.map_err(|e| format!("host_workspace_file_set: lock: {e}"))?;
let runtime = ctx.runtime_context_provider.get_context(&ctx.plugin_id);
let workspace_path = runtime
.get("current_workspace")
.and_then(|value| value.as_object())
.and_then(|workspace| workspace.get("path"))
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.ok_or("host_workspace_file_set: missing current_workspace.path")?;
ctx.check_perm(PermissionType::ReadFiles, workspace_path)
.map_err(|e| e.to_string())?;
let workspace = diaryx_core::workspace::Workspace::new(ctx.fs.as_ref());
let workspace_path = Path::new(workspace_path);
let root_index = if workspace_path
.extension()
.is_some_and(|extension| extension == "md")
{
workspace_path.to_path_buf()
} else {
futures_lite::future::block_on(workspace.find_root_index_in_dir(workspace_path))
.map_err(|e| format!("host_workspace_file_set: {e}"))?
.ok_or("host_workspace_file_set: workspace root index not found")?
};
futures_lite::future::block_on(workspace.collect_workspace_file_set(&root_index))
.map_err(|e| format!("host_workspace_file_set: {e}"))
}
match inner(&user_data) {
Ok(files) => {
let json = serde_json::to_string(&files).map_err(|e| {
ExtismError::msg(format!("host_workspace_file_set: serialize: {e}"))
})?;
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
}
Err(msg) => {
plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
}
}
Ok(())
}
fn host_file_exists(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct ExistsInput {
path: String,
}
let parsed: ExistsInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_file_exists: invalid input: {e}")))?;
let path = HostContext::validate_file_path(&parsed.path)?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_file_exists: lock: {e}")))?;
ctx.check_perm(PermissionType::ReadFiles, &path)?;
let exists = futures_lite::future::block_on(ctx.fs.exists(Path::new(&path)));
let json = serde_json::to_string(&exists)
.map_err(|e| ExtismError::msg(format!("host_file_exists: serialize: {e}")))?;
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
fn host_file_metadata(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct MetadataInput {
path: String,
}
let parsed: MetadataInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_file_metadata: invalid input: {e}")))?;
let validated_path = HostContext::validate_file_path(&parsed.path)?;
let not_found = serde_json::json!({
"exists": false,
"size_bytes": serde_json::Value::Null,
"modified_at_ms": serde_json::Value::Null,
})
.to_string();
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_file_metadata: lock: {e}")))?;
if ctx
.check_perm(PermissionType::ReadFiles, &validated_path)
.is_err()
{
plugin.memory_set_val(&mut outputs[0], not_found.as_str())?;
return Ok(());
}
let path = Path::new(&validated_path);
let exists = futures_lite::future::block_on(ctx.fs.exists(path));
let json = if exists {
let size_bytes = futures_lite::future::block_on(ctx.fs.get_file_size(path));
let modified_at_ms = futures_lite::future::block_on(ctx.fs.get_modified_time(path));
serde_json::json!({
"exists": true,
"size_bytes": size_bytes,
"modified_at_ms": modified_at_ms,
})
.to_string()
} else {
not_found
};
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
fn host_write_file(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct WriteInput {
path: String,
content: String,
}
let parsed: WriteInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_write_file: invalid input: {e}")))?;
let path = HostContext::validate_file_path(&parsed.path)?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_write_file: lock: {e}")))?;
let exists = futures_lite::future::block_on(ctx.fs.exists(Path::new(&path)));
let perm = if exists {
PermissionType::EditFiles
} else {
PermissionType::CreateFiles
};
if let Err(e) = ctx.check_perm(perm, &path) {
plugin.memory_set_val(&mut outputs[0], e.to_string().as_str())?;
return Ok(());
}
if let Err(e) =
futures_lite::future::block_on(ctx.fs.write_file(Path::new(&path), &parsed.content))
{
let msg = format!("host_write_file: {e}");
plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
return Ok(());
}
plugin.memory_set_val(&mut outputs[0], "")?;
Ok(())
}
fn host_delete_file(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct DeleteInput {
path: String,
}
let parsed: DeleteInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_delete_file: invalid input: {e}")))?;
let path = HostContext::validate_file_path(&parsed.path)?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_delete_file: lock: {e}")))?;
if let Err(e) = ctx.check_perm(PermissionType::DeleteFiles, &path) {
plugin.memory_set_val(&mut outputs[0], e.to_string().as_str())?;
return Ok(());
}
if let Err(e) = futures_lite::future::block_on(ctx.fs.delete_file(Path::new(&path))) {
let msg = format!("host_delete_file: {e}");
plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
return Ok(());
}
plugin.memory_set_val(&mut outputs[0], "")?;
Ok(())
}
fn host_write_binary(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
use base64::Engine;
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct WriteBinaryInput {
path: String,
content: String, }
let parsed: WriteBinaryInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_write_binary: invalid input: {e}")))?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&parsed.content)
.map_err(|e| ExtismError::msg(format!("host_write_binary: base64 decode: {e}")))?;
let path = HostContext::validate_file_path(&parsed.path)?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_write_binary: lock: {e}")))?;
let exists = futures_lite::future::block_on(ctx.fs.exists(Path::new(&path)));
let perm = if exists {
PermissionType::EditFiles
} else {
PermissionType::CreateFiles
};
if let Err(e) = ctx.check_perm(perm, &path) {
plugin.memory_set_val(&mut outputs[0], e.to_string().as_str())?;
return Ok(());
}
if let Err(e) = futures_lite::future::block_on(ctx.fs.write_binary(Path::new(&path), &bytes)) {
let msg = format!("host_write_binary: {e}");
plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
return Ok(());
}
plugin.memory_set_val(&mut outputs[0], "")?;
Ok(())
}
fn host_emit_event(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let event_json: String = plugin.memory_get_val(&inputs[0])?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_emit_event: lock: {e}")))?;
ctx.event_emitter.emit(&event_json);
plugin.memory_set_val(&mut outputs[0], "")?;
Ok(())
}
fn host_storage_get(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
use base64::Engine;
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct StorageGetInput {
key: String,
}
let parsed: StorageGetInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_storage_get: invalid input: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_storage_get: lock: {e}")))?;
if ctx
.check_perm(PermissionType::PluginStorage, &parsed.key)
.is_err()
{
plugin.memory_set_val(&mut outputs[0], "")?;
return Ok(());
}
let storage_key = ctx.storage_key(&parsed.key);
let result = match ctx.storage.get(&storage_key) {
Some(data) => {
let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
serde_json::json!({ "data": encoded }).to_string()
}
None => String::new(),
};
plugin.memory_set_val(&mut outputs[0], result.as_str())?;
Ok(())
}
fn host_storage_set(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
use base64::Engine;
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct StorageSetInput {
key: String,
data: String, }
let parsed: StorageSetInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_storage_set: invalid input: {e}")))?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&parsed.data)
.map_err(|e| ExtismError::msg(format!("host_storage_set: base64 decode: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_storage_set: lock: {e}")))?;
if let Err(e) = ctx.check_perm(PermissionType::PluginStorage, &parsed.key) {
let msg = format!("host_storage_set: {e}");
plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
return Ok(());
}
if ctx.storage_quota_bytes > 0 && bytes.len() as u64 > ctx.storage_quota_bytes {
let msg = format!(
"host_storage_set: data size ({} bytes) exceeds plugin storage quota ({} bytes)",
bytes.len(),
ctx.storage_quota_bytes
);
plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
return Ok(());
}
let storage_key = ctx.storage_key(&parsed.key);
ctx.storage.set(&storage_key, &bytes);
plugin.memory_set_val(&mut outputs[0], "")?;
Ok(())
}
fn host_secret_get(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct SecretGetInput {
key: String,
}
let parsed: SecretGetInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_secret_get: invalid input: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_secret_get: lock: {e}")))?;
ctx.check_perm(PermissionType::PluginStorage, &parsed.key)?;
let secret_key = ctx.secret_key(&parsed.key);
let result = match ctx.secret_store.get(&secret_key) {
Some(value) => serde_json::json!({ "value": value }).to_string(),
None => String::new(),
};
plugin.memory_set_val(&mut outputs[0], result.as_str())?;
Ok(())
}
fn host_secret_set(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct SecretSetInput {
key: String,
value: String,
}
let parsed: SecretSetInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_secret_set: invalid input: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_secret_set: lock: {e}")))?;
ctx.check_perm(PermissionType::PluginStorage, &parsed.key)?;
let secret_key = ctx.secret_key(&parsed.key);
ctx.secret_store.set(&secret_key, &parsed.value);
plugin.memory_set_val(&mut outputs[0], "")?;
Ok(())
}
fn host_secret_delete(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct SecretDeleteInput {
key: String,
}
let parsed: SecretDeleteInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_secret_delete: invalid input: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_secret_delete: lock: {e}")))?;
ctx.check_perm(PermissionType::PluginStorage, &parsed.key)?;
let secret_key = ctx.secret_key(&parsed.key);
ctx.secret_store.delete(&secret_key);
plugin.memory_set_val(&mut outputs[0], "")?;
Ok(())
}
#[cfg(feature = "wasi-runner")]
fn host_run_wasi_module(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
use base64::Engine;
let input: String = plugin.memory_get_val(&inputs[0])?;
let request: crate::wasi_runner::WasiRunRequest = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_run_wasi_module: invalid input: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_run_wasi_module: lock: {e}")))?;
ctx.check_perm(PermissionType::PluginStorage, &request.module_key)?;
let storage_key = ctx.storage_key(&request.module_key);
let wasm_bytes = ctx.storage.get(&storage_key).ok_or_else(|| {
ExtismError::msg(format!(
"host_run_wasi_module: module not found in storage: {}",
request.module_key
))
})?;
drop(ctx);
let decoded_files = if let Some(ref files) = request.files {
let mut map = std::collections::HashMap::new();
for (path, b64) in files {
let data = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| {
ExtismError::msg(format!(
"host_run_wasi_module: base64 decode for {path}: {e}"
))
})?;
map.insert(path.clone(), data);
}
Some(map)
} else {
None
};
let stdin_bytes = if let Some(ref b64) = request.stdin {
Some(
base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| {
ExtismError::msg(format!("host_run_wasi_module: stdin base64 decode: {e}"))
})?,
)
} else {
None
};
let result = crate::wasi_runner::run_wasi_module(
&wasm_bytes,
&request.args,
stdin_bytes.as_deref(),
decoded_files.as_ref(),
request.output_files.as_deref(),
)
.map_err(|e| ExtismError::msg(format!("host_run_wasi_module: {e}")))?;
let json = serde_json::to_string(&result)
.map_err(|e| ExtismError::msg(format!("host_run_wasi_module: serialize: {e}")))?;
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
#[cfg(not(feature = "wasi-runner"))]
fn host_run_wasi_module(
plugin: &mut CurrentPlugin,
_inputs: &[Val],
outputs: &mut [Val],
_user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let error = serde_json::json!({
"exit_code": -1,
"stdout": "",
"stderr": "host_run_wasi_module: wasi-runner feature not enabled"
});
plugin.memory_set_val(&mut outputs[0], error.to_string().as_str())?;
Ok(())
}
fn host_get_timestamp(
plugin: &mut CurrentPlugin,
_inputs: &[Val],
outputs: &mut [Val],
_user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
plugin.memory_set_val(&mut outputs[0], now.to_string().as_str())?;
Ok(())
}
fn host_get_now(
plugin: &mut CurrentPlugin,
_inputs: &[Val],
outputs: &mut [Val],
_user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let now = Local::now().to_rfc3339_opts(SecondsFormat::Secs, false);
plugin.memory_set_val(&mut outputs[0], now.as_str())?;
Ok(())
}
fn host_request_file(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct RequestFileInput {
key: String,
}
let parsed: RequestFileInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_request_file: invalid input: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_request_file: lock: {e}")))?;
let result = ctx
.file_provider
.get_file(&ctx.plugin_id, &parsed.key)
.unwrap_or_default();
plugin.memory_set_val(&mut outputs[0], result.as_slice())?;
Ok(())
}
fn host_plugin_command(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
#[derive(serde::Deserialize)]
struct PluginCommandInput {
plugin_id: String,
command: String,
#[serde(default)]
params: serde_json::Value,
}
let input: String = plugin.memory_get_val(&inputs[0])?;
let parsed: PluginCommandInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_plugin_command: invalid input: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_plugin_command: lock: {e}")))?;
const MAX_PLUGIN_COMMAND_DEPTH: u32 = 8;
let response = if ctx.plugin_command_depth >= MAX_PLUGIN_COMMAND_DEPTH {
serde_json::json!({
"success": false,
"error": format!(
"Cross-plugin command call depth limit exceeded (max {MAX_PLUGIN_COMMAND_DEPTH})"
),
})
} else if parsed.plugin_id.trim().is_empty() || parsed.command.trim().is_empty() {
serde_json::json!({
"success": false,
"error": "plugin_id and command are required",
})
} else if parsed.plugin_id == ctx.plugin_id {
serde_json::json!({
"success": false,
"error": "Plugins cannot call their own commands via host_plugin_command",
})
} else {
let permission_target = format!("{}:{}", parsed.plugin_id, parsed.command);
match ctx.check_perm(PermissionType::ExecuteCommands, &permission_target) {
Ok(()) => match ctx.plugin_command_bridge.call(
&ctx.plugin_id,
&parsed.plugin_id,
&parsed.command,
parsed.params,
) {
Ok(data) => serde_json::json!({
"success": true,
"data": data,
}),
Err(error) => serde_json::json!({
"success": false,
"error": error,
}),
},
Err(error) => serde_json::json!({
"success": false,
"error": error.to_string(),
}),
}
};
let json = serde_json::to_string(&response)
.map_err(|e| ExtismError::msg(format!("host_plugin_command: serialize: {e}")))?;
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
fn host_get_runtime_context(
plugin: &mut CurrentPlugin,
_inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_get_runtime_context: lock: {e}")))?;
let json = serde_json::to_string(&ctx.runtime_context_provider.get_context(&ctx.plugin_id))
.map_err(|e| ExtismError::msg(format!("host_get_runtime_context: serialize: {e}")))?;
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
fn host_ws_request(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_ws_request: lock: {e}")))?;
let result = ctx.ws_bridge.request(&input).map_err(ExtismError::msg)?;
plugin.memory_set_val(&mut outputs[0], result.as_str())?;
Ok(())
}
fn host_hash_file(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct HashInput {
path: String,
}
let parsed: HashInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_hash_file: invalid input: {e}")))?;
let path = HostContext::validate_file_path(&parsed.path)?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_hash_file: lock: {e}")))?;
if ctx.check_perm(PermissionType::ReadFiles, &path).is_err() {
plugin.memory_set_val(&mut outputs[0], "")?;
return Ok(());
}
let hash = match futures_lite::future::block_on(ctx.fs.hash_file(Path::new(&path))) {
Ok(hash) => hash,
Err(_) => {
plugin.memory_set_val(&mut outputs[0], "")?;
return Ok(());
}
};
let json = serde_json::json!({ "hash": hash }).to_string();
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
#[cfg(feature = "http")]
fn host_proxy_request(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct ProxyInput {
proxy_id: String,
#[serde(default)]
path: String,
#[serde(default = "default_method")]
method: String,
#[serde(default)]
headers: std::collections::HashMap<String, String>,
body: Option<String>,
}
fn default_method() -> String {
"POST".to_string()
}
#[derive(serde::Serialize)]
struct ProxyOutput {
status: u16,
headers: std::collections::HashMap<String, String>,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
body_base64: Option<String>,
}
let parsed: ProxyInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_proxy_request: invalid input: {e}")))?;
HostContext::validate_http_headers(&parsed.headers)?;
let (server_url, auth_token) = {
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_proxy_request: lock: {e}")))?;
let runtime_json = ctx.runtime_context_provider.get_context(&ctx.plugin_id);
let server_url = runtime_json
.get("server_url")
.and_then(|v| v.as_str())
.map(|s| s.trim_end_matches('/').to_string())
.ok_or_else(|| {
ExtismError::msg("host_proxy_request: server_url not available in runtime context")
})?;
let auth_token = runtime_json
.get("auth_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
(server_url, auth_token)
};
let proxy_url = if parsed.path.is_empty() {
format!("{}/api/proxy/{}", server_url, parsed.proxy_id)
} else {
format!(
"{}/api/proxy/{}/{}",
server_url,
parsed.proxy_id,
parsed.path.trim_start_matches('/')
)
};
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(120)))
.http_status_as_error(false)
.build()
.into();
let mut request_builder = ureq::http::Request::builder()
.method(parsed.method.as_str())
.uri(proxy_url.as_str())
.header("Content-Type", "application/json");
if let Some(ref token) = auth_token {
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
}
for (key, value) in &parsed.headers {
request_builder = request_builder.header(key, value);
}
let response = if let Some(body) = &parsed.body {
let request = request_builder
.body(body.clone())
.map_err(|e| ExtismError::msg(format!("host_proxy_request: build request: {e}")))?;
agent
.run(request)
.map_err(|e| ExtismError::msg(format!("host_proxy_request: {e}")))?
} else {
let request = request_builder
.body(())
.map_err(|e| ExtismError::msg(format!("host_proxy_request: build request: {e}")))?;
agent
.run(request)
.map_err(|e| ExtismError::msg(format!("host_proxy_request: {e}")))?
};
let status = response.status().as_u16();
let mut resp_headers = std::collections::HashMap::new();
for (name, value) in response.headers() {
if let Ok(v) = value.to_str() {
resp_headers.insert(name.to_string(), v.to_string());
}
}
let mut response = response;
let body_bytes = response
.body_mut()
.read_to_vec()
.map_err(|e| ExtismError::msg(format!("host_proxy_request: read body: {e}")))?;
let body = String::from_utf8_lossy(&body_bytes).to_string();
use base64::Engine as _;
let body_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes));
let output = ProxyOutput {
status,
headers: resp_headers,
body,
body_base64,
};
let json = serde_json::to_string(&output)
.map_err(|e| ExtismError::msg(format!("host_proxy_request: serialize: {e}")))?;
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
#[cfg(feature = "http")]
fn host_http_request(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
use base64::Engine as _;
use ureq::http::Request;
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct HttpInput {
url: String,
method: String,
headers: std::collections::HashMap<String, String>,
body: Option<String>,
body_base64: Option<String>,
timeout_ms: Option<u64>,
}
#[derive(serde::Serialize)]
struct HttpOutput {
status: u16,
headers: std::collections::HashMap<String, String>,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
body_base64: Option<String>,
}
let parsed: HttpInput = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_http_request: invalid input: {e}")))?;
HostContext::validate_http_headers(&parsed.headers)?;
{
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_http_request: lock: {e}")))?;
ctx.check_perm(PermissionType::HttpRequests, &parsed.url)?;
}
const MIN_HTTP_TIMEOUT_MS: u64 = 1_000;
const MAX_HTTP_TIMEOUT_MS: u64 = 300_000;
let timeout = parsed
.timeout_ms
.map(|value| value.clamp(MIN_HTTP_TIMEOUT_MS, MAX_HTTP_TIMEOUT_MS))
.map(std::time::Duration::from_millis);
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(timeout)
.http_status_as_error(false)
.build()
.into();
let mut request_builder = Request::builder()
.method(parsed.method.as_str())
.uri(parsed.url.as_str());
for (key, value) in &parsed.headers {
request_builder = request_builder.header(key, value);
}
let response = if let Some(b64) = &parsed.body_base64 {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| ExtismError::msg(format!("host_http_request: base64 decode: {e}")))?;
let request = request_builder
.body(bytes)
.map_err(|e| ExtismError::msg(format!("host_http_request: invalid request: {e}")))?;
agent
.run(request)
.map_err(|e| ExtismError::msg(format!("host_http_request: {e}")))?
} else if let Some(body) = &parsed.body {
let request = request_builder
.body(body.clone())
.map_err(|e| ExtismError::msg(format!("host_http_request: invalid request: {e}")))?;
agent
.run(request)
.map_err(|e| ExtismError::msg(format!("host_http_request: {e}")))?
} else {
let request = request_builder
.body(())
.map_err(|e| ExtismError::msg(format!("host_http_request: invalid request: {e}")))?;
agent
.run(request)
.map_err(|e| ExtismError::msg(format!("host_http_request: {e}")))?
};
let status = response.status().as_u16();
if status >= 400 {
log::warn!(
"host_http_request: {} {} → {} (plugin={})",
parsed.method,
parsed.url,
status,
{
let ctx = user_data.get().ok();
ctx.and_then(|c| c.lock().ok().map(|g| g.plugin_id.clone()))
.unwrap_or_default()
},
);
}
let mut resp_headers = std::collections::HashMap::new();
for (name, value) in response.headers() {
if let Ok(value) = value.to_str() {
resp_headers.insert(name.to_string(), value.to_string());
}
}
let mut response = response;
let body_bytes = response
.body_mut()
.read_to_vec()
.map_err(|e| ExtismError::msg(format!("host_http_request: read body: {e}")))?;
let body = String::from_utf8_lossy(&body_bytes).to_string();
let body_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes));
let output = HttpOutput {
status,
headers: resp_headers,
body,
body_base64,
};
let json = serde_json::to_string(&output)
.map_err(|e| ExtismError::msg(format!("host_http_request: serialize: {e}")))?;
plugin.memory_set_val(&mut outputs[0], json.as_str())?;
Ok(())
}
#[cfg(not(feature = "http"))]
fn host_http_request(
plugin: &mut CurrentPlugin,
_inputs: &[Val],
outputs: &mut [Val],
_user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let error = serde_json::json!({
"status": 0,
"headers": {},
"body": "host_http_request: http feature not enabled"
});
plugin.memory_set_val(&mut outputs[0], error.to_string().as_str())?;
Ok(())
}
#[cfg(not(feature = "http"))]
fn host_proxy_request(
plugin: &mut CurrentPlugin,
_inputs: &[Val],
outputs: &mut [Val],
_user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let error = serde_json::json!({
"status": 0,
"headers": {},
"body": "host_proxy_request: http feature not enabled"
});
plugin.memory_set_val(&mut outputs[0], error.to_string().as_str())?;
Ok(())
}
fn host_namespace_put_object(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
use base64::Engine as _;
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct Input {
ns_id: String,
key: String,
body_base64: String,
mime_type: String,
#[serde(default)]
audience: Option<String>,
}
let parsed: Input = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_namespace_put_object: invalid input: {e}")))?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&parsed.body_base64)
.map_err(|e| ExtismError::msg(format!("host_namespace_put_object: base64 decode: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_namespace_put_object: lock: {e}")))?;
let result = ctx.namespace_provider.put_object(
&parsed.ns_id,
&parsed.key,
&bytes,
&parsed.mime_type,
parsed.audience.as_deref(),
);
let json = match result {
Ok(()) => serde_json::json!({ "ok": true }),
Err(e) => serde_json::json!({ "error": e }),
};
plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
Ok(())
}
fn host_namespace_get_object(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
use base64::Engine as _;
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct Input {
ns_id: String,
key: String,
}
let parsed: Input = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_namespace_get_object: invalid input: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_namespace_get_object: lock: {e}")))?;
let result = ctx
.namespace_provider
.get_object(&parsed.ns_id, &parsed.key);
let json = match result {
Ok(bytes) => {
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
serde_json::json!({ "data": encoded })
}
Err(e) => serde_json::json!({ "error": e }),
};
plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
Ok(())
}
fn host_namespace_delete_object(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct Input {
ns_id: String,
key: String,
}
let parsed: Input = serde_json::from_str(&input).map_err(|e| {
ExtismError::msg(format!("host_namespace_delete_object: invalid input: {e}"))
})?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_namespace_delete_object: lock: {e}")))?;
let result = ctx
.namespace_provider
.delete_object(&parsed.ns_id, &parsed.key);
let json = match result {
Ok(()) => serde_json::json!({ "ok": true }),
Err(e) => serde_json::json!({ "error": e }),
};
plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
Ok(())
}
fn host_namespace_list_objects(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct Input {
ns_id: String,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
offset: Option<u32>,
}
let parsed: Input = serde_json::from_str(&input).map_err(|e| {
ExtismError::msg(format!("host_namespace_list_objects: invalid input: {e}"))
})?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_namespace_list_objects: lock: {e}")))?;
let result = ctx.namespace_provider.list_objects(
&parsed.ns_id,
parsed.prefix.as_deref(),
parsed.limit,
parsed.offset,
);
let json = match result {
Ok(objects) => serde_json::to_value(&objects).unwrap_or(serde_json::json!([])),
Err(e) => serde_json::json!({ "error": e }),
};
plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
Ok(())
}
fn host_namespace_list(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let _input: String = plugin.memory_get_val(&inputs[0])?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_namespace_list: lock: {e}")))?;
let result = ctx.namespace_provider.list_namespaces();
let json = match result {
Ok(entries) => serde_json::to_value(&entries).unwrap_or(serde_json::json!([])),
Err(e) => serde_json::json!({ "error": e }),
};
plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
Ok(())
}
fn host_namespace_create(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct Input {
#[serde(default)]
metadata: Option<serde_json::Value>,
}
let parsed: Input = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_namespace_create: invalid input: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_namespace_create: lock: {e}")))?;
let result = ctx
.namespace_provider
.create_namespace(parsed.metadata.as_ref());
let json = match result {
Ok(entry) => serde_json::to_value(&entry).unwrap_or_else(|_| serde_json::json!({})),
Err(e) => serde_json::json!({ "error": e }),
};
plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
Ok(())
}
fn host_namespace_sync_audience(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct Input {
ns_id: String,
audience: String,
access: String,
}
let parsed: Input = serde_json::from_str(&input).map_err(|e| {
ExtismError::msg(format!("host_namespace_sync_audience: invalid input: {e}"))
})?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_namespace_sync_audience: lock: {e}")))?;
let result =
ctx.namespace_provider
.sync_audience(&parsed.ns_id, &parsed.audience, &parsed.access);
let json = match result {
Ok(()) => serde_json::json!({ "ok": true }),
Err(e) => serde_json::json!({ "error": e }),
};
plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
Ok(())
}
fn host_namespace_send_email(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostContext>,
) -> Result<(), ExtismError> {
let input: String = plugin.memory_get_val(&inputs[0])?;
#[derive(serde::Deserialize)]
struct Input {
ns_id: String,
audience: String,
subject: String,
reply_to: Option<String>,
}
let parsed: Input = serde_json::from_str(&input)
.map_err(|e| ExtismError::msg(format!("host_namespace_send_email: invalid input: {e}")))?;
let ctx = user_data.get()?;
let ctx = ctx
.lock()
.map_err(|e| ExtismError::msg(format!("host_namespace_send_email: lock: {e}")))?;
let result = ctx.namespace_provider.send_audience_email(
&parsed.ns_id,
&parsed.audience,
&parsed.subject,
parsed.reply_to.as_deref(),
);
let json = match result {
Ok(val) => val,
Err(e) => serde_json::json!({ "error": e }),
};
plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
Ok(())
}