Skip to main content

diaryx_extism/
host_fns.rs

1//! Host functions exposed to guest WASM plugins.
2//!
3//! These functions give guest plugins controlled, sandboxed access to the
4//! Diaryx environment. They are registered with the Extism plugin via
5//! [`PluginBuilder`](extism::PluginBuilder).
6
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use chrono::{Local, SecondsFormat};
11use diaryx_core::fs::AsyncFileSystem;
12use diaryx_core::plugin::permissions::PermissionType;
13use extism::{CurrentPlugin, Error as ExtismError, UserData, Val, ValType};
14
15use crate::permission_checker::DenyAllPermissionChecker;
16
17/// Trait for persisting plugin state (CRDT snapshots, config, etc.).
18///
19/// Implementations might use SQLite on native or IndexedDB on web.
20pub trait PluginStorage: Send + Sync {
21    /// Load a value by key.
22    fn get(&self, key: &str) -> Option<Vec<u8>>;
23    /// Store a value by key.
24    fn set(&self, key: &str, data: &[u8]);
25    /// Delete a value by key.
26    fn delete(&self, key: &str);
27}
28
29/// Trait for persisting plugin secrets separately from normal plugin state.
30pub trait PluginSecretStore: Send + Sync {
31    /// Load a secret by key.
32    fn get(&self, key: &str) -> Option<String>;
33    /// Store a secret by key.
34    fn set(&self, key: &str, value: &str);
35    /// Delete a secret by key.
36    fn delete(&self, key: &str);
37}
38
39/// Trait for emitting events from plugins to the host application.
40pub trait EventEmitter: Send + Sync {
41    /// Emit an event (JSON payload) to the host.
42    fn emit(&self, event_json: &str);
43}
44
45/// Trait for handling plugin-initiated websocket transport requests.
46pub trait WebSocketBridge: Send + Sync {
47    /// Handle a serialized websocket request and return a serialized response.
48    fn request(&self, request_json: &str) -> Result<String, String>;
49}
50
51/// Trait for plugin-to-plugin command dispatch mediated by the host.
52pub trait PluginCommandBridge: Send + Sync {
53    /// Execute a command on another plugin and return the plugin's raw JSON data.
54    fn call(
55        &self,
56        caller_plugin_id: &str,
57        plugin_id: &str,
58        command: &str,
59        params: serde_json::Value,
60    ) -> Result<serde_json::Value, String>;
61}
62
63/// Trait for exposing generic host runtime context to plugins.
64pub trait RuntimeContextProvider: Send + Sync {
65    /// Return runtime context for the caller plugin.
66    fn get_context(&self, plugin_id: &str) -> serde_json::Value;
67}
68
69/// Metadata for a single object in a namespace.
70#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
71pub struct NamespaceObjectMeta {
72    #[serde(default)]
73    pub namespace_id: Option<String>,
74    pub key: String,
75    #[serde(default)]
76    pub r2_key: Option<String>,
77    #[serde(default)]
78    pub audience: Option<String>,
79    #[serde(default)]
80    pub mime_type: Option<String>,
81    #[serde(default)]
82    pub size_bytes: Option<u64>,
83    #[serde(default)]
84    pub updated_at: Option<i64>,
85    #[serde(default)]
86    pub content_hash: Option<String>,
87}
88
89/// Entry returned by `list_namespaces` — mirrors the server's `NamespaceResponse`.
90#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
91pub struct NamespaceEntry {
92    pub id: String,
93    pub owner_user_id: String,
94    pub created_at: i64,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub metadata: Option<serde_json::Value>,
97}
98
99/// A single entry in a batch-get response.
100#[derive(Debug, Clone)]
101pub struct BatchGetEntry {
102    pub bytes: Vec<u8>,
103    pub mime_type: String,
104}
105
106/// Result of a batch object download.
107#[derive(Debug, Clone, Default)]
108pub struct BatchGetResult {
109    pub objects: std::collections::HashMap<String, BatchGetEntry>,
110    pub errors: std::collections::HashMap<String, String>,
111}
112
113/// Trait for namespace object operations (upload, delete, list, sync audience).
114///
115/// Implementations talk to the sync server — via `proxyFetch` on browser,
116/// via `ureq` on native.
117pub trait NamespaceProvider: Send + Sync {
118    fn create_namespace(
119        &self,
120        metadata: Option<&serde_json::Value>,
121    ) -> Result<NamespaceEntry, String>;
122    fn put_object(
123        &self,
124        ns_id: &str,
125        key: &str,
126        bytes: &[u8],
127        mime_type: &str,
128        audience: Option<&str>,
129    ) -> Result<(), String>;
130    fn get_object(&self, ns_id: &str, key: &str) -> Result<Vec<u8>, String>;
131    fn delete_object(&self, ns_id: &str, key: &str) -> Result<(), String>;
132    fn list_objects(
133        &self,
134        ns_id: &str,
135        prefix: Option<&str>,
136        limit: Option<u32>,
137        offset: Option<u32>,
138    ) -> Result<Vec<NamespaceObjectMeta>, String>;
139    fn sync_audience(&self, ns_id: &str, audience: &str, access: &str) -> Result<(), String>;
140    /// Trigger sending the email draft for an audience to all subscribers.
141    fn send_audience_email(
142        &self,
143        ns_id: &str,
144        audience: &str,
145        subject: &str,
146        reply_to: Option<&str>,
147    ) -> Result<serde_json::Value, String>;
148
149    /// Download multiple objects in a single request.
150    fn get_objects_batch(&self, ns_id: &str, keys: &[String]) -> Result<BatchGetResult, String>;
151
152    /// List all namespaces owned by the authenticated user.
153    fn list_namespaces(&self) -> Result<Vec<NamespaceEntry>, String>;
154}
155
156/// Parse a `multipart/mixed` response body into a [`BatchGetResult`].
157///
158/// Each part is identified by its `Content-Disposition: attachment; filename="<key>"`
159/// header. Parts with an `X-Batch-Error: true` header are treated as per-key errors.
160pub fn parse_multipart_batch(body: &[u8], boundary: &str) -> BatchGetResult {
161    let mut result = BatchGetResult::default();
162    let delim = format!("--{boundary}");
163    let closing = format!("--{boundary}--");
164
165    // Split body on boundary markers.
166    let delim_bytes = delim.as_bytes();
167    let mut parts: Vec<&[u8]> = Vec::new();
168    let mut start = 0;
169
170    while let Some(pos) = memmem(body, start, delim_bytes) {
171        if start > 0 {
172            // Trim trailing \r\n before boundary.
173            let end = if pos >= 2 && body[pos - 2] == b'\r' && body[pos - 1] == b'\n' {
174                pos - 2
175            } else {
176                pos
177            };
178            parts.push(&body[start..end]);
179        }
180        start = pos + delim_bytes.len();
181        // Skip \r\n after boundary line.
182        if start < body.len() && body[start] == b'\r' {
183            start += 1;
184        }
185        if start < body.len() && body[start] == b'\n' {
186            start += 1;
187        }
188        // Check for closing boundary (--boundary--).
189        if start >= 2 && body[start - 2..start].starts_with(b"--") {
190            break;
191        }
192    }
193
194    for part in parts {
195        // Split headers from body at the first \r\n\r\n.
196        let header_end = match memmem(part, 0, b"\r\n\r\n") {
197            Some(pos) => pos,
198            None => continue,
199        };
200        let header_section = &part[..header_end];
201        let body_section = &part[header_end + 4..];
202
203        let headers_str = String::from_utf8_lossy(header_section);
204        let mut filename: Option<String> = None;
205        let mut content_type = "application/octet-stream".to_string();
206        let mut is_error = false;
207
208        for line in headers_str.split("\r\n") {
209            let lower = line.to_ascii_lowercase();
210            if lower.starts_with("content-disposition:") {
211                if let Some(pos) = line.find("filename=\"") {
212                    let start = pos + 10;
213                    if let Some(end) = line[start..].find('\"') {
214                        filename = Some(line[start..start + end].replace("\\\"", "\""));
215                    }
216                }
217            } else if lower.starts_with("content-type:") {
218                content_type = line["content-type:".len()..].trim().to_string();
219            } else if lower.starts_with("x-batch-error:") {
220                is_error = lower.contains("true");
221            }
222        }
223
224        if let Some(key) = filename {
225            if is_error {
226                let msg = String::from_utf8_lossy(body_section).to_string();
227                result.errors.insert(key, msg);
228            } else {
229                result.objects.insert(
230                    key,
231                    BatchGetEntry {
232                        bytes: body_section.to_vec(),
233                        mime_type: content_type,
234                    },
235                );
236            }
237        }
238    }
239
240    result
241}
242
243/// Find the first occurrence of `needle` in `haystack` starting from `offset`.
244fn memmem(haystack: &[u8], offset: usize, needle: &[u8]) -> Option<usize> {
245    if needle.is_empty() || offset + needle.len() > haystack.len() {
246        return None;
247    }
248    haystack[offset..]
249        .windows(needle.len())
250        .position(|w| w == needle)
251        .map(|p| p + offset)
252}
253
254#[cfg(test)]
255mod multipart_tests {
256    use super::*;
257
258    fn build_multipart(boundary: &str, parts: &[(&str, &str, &[u8], bool)]) -> Vec<u8> {
259        let mut buf = Vec::new();
260        for (key, mime, body, is_error) in parts {
261            buf.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
262            buf.extend_from_slice(
263                format!("Content-Disposition: attachment; filename=\"{key}\"\r\n").as_bytes(),
264            );
265            if *is_error {
266                buf.extend_from_slice(b"X-Batch-Error: true\r\n");
267            }
268            buf.extend_from_slice(format!("Content-Type: {mime}\r\n").as_bytes());
269            buf.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
270            buf.extend_from_slice(b"\r\n");
271            buf.extend_from_slice(body);
272            buf.extend_from_slice(b"\r\n");
273        }
274        buf.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
275        buf
276    }
277
278    #[test]
279    fn parses_text_and_binary_parts() {
280        let boundary = "test-boundary-123";
281        let body = build_multipart(
282            boundary,
283            &[
284                ("files/readme.md", "text/markdown", b"# Hello", false),
285                (
286                    "files/image.png",
287                    "image/png",
288                    &[0x89, 0x50, 0x4E, 0x47],
289                    false,
290                ),
291            ],
292        );
293        let result = parse_multipart_batch(&body, boundary);
294        assert_eq!(result.objects.len(), 2);
295        assert!(result.errors.is_empty());
296
297        let md = result.objects.get("files/readme.md").unwrap();
298        assert_eq!(md.bytes, b"# Hello");
299        assert_eq!(md.mime_type, "text/markdown");
300
301        let img = result.objects.get("files/image.png").unwrap();
302        assert_eq!(img.bytes, &[0x89, 0x50, 0x4E, 0x47]);
303        assert_eq!(img.mime_type, "image/png");
304    }
305
306    #[test]
307    fn parses_error_parts() {
308        let boundary = "err-boundary";
309        let body = build_multipart(
310            boundary,
311            &[
312                ("files/ok.md", "text/markdown", b"content", false),
313                ("files/missing.md", "text/plain", b"Object not found", true),
314            ],
315        );
316        let result = parse_multipart_batch(&body, boundary);
317        assert_eq!(result.objects.len(), 1);
318        assert_eq!(result.errors.len(), 1);
319        assert_eq!(
320            result.errors.get("files/missing.md").unwrap(),
321            "Object not found"
322        );
323    }
324
325    #[test]
326    fn handles_empty_batch() {
327        let boundary = "empty";
328        let body = format!("--{boundary}--\r\n").into_bytes();
329        let result = parse_multipart_batch(&body, boundary);
330        assert!(result.objects.is_empty());
331        assert!(result.errors.is_empty());
332    }
333}
334
335/// No-op implementation of [`PluginStorage`] for plugins that don't need persistence.
336pub struct NoopStorage;
337
338impl PluginStorage for NoopStorage {
339    fn get(&self, _key: &str) -> Option<Vec<u8>> {
340        None
341    }
342    fn set(&self, _key: &str, _data: &[u8]) {}
343    fn delete(&self, _key: &str) {}
344}
345
346/// No-op implementation of [`PluginSecretStore`] for hosts without secure storage.
347pub struct NoopSecretStore;
348
349impl PluginSecretStore for NoopSecretStore {
350    fn get(&self, _key: &str) -> Option<String> {
351        None
352    }
353
354    fn set(&self, _key: &str, _value: &str) {}
355
356    fn delete(&self, _key: &str) {}
357}
358
359fn sanitize_storage_key(key: &str) -> String {
360    key.chars()
361        .map(|c| {
362            if c == '/' || c == '\\' || c == ':' {
363                '_'
364            } else {
365                c
366            }
367        })
368        .collect()
369}
370
371/// File-backed [`PluginStorage`] implementation for native hosts.
372pub struct FilePluginStorage {
373    base_dir: PathBuf,
374}
375
376impl FilePluginStorage {
377    pub fn new(base_dir: PathBuf) -> Self {
378        let _ = std::fs::create_dir_all(&base_dir);
379        Self { base_dir }
380    }
381
382    fn key_to_path(&self, key: &str) -> PathBuf {
383        self.base_dir
384            .join(format!("{}.bin", sanitize_storage_key(key)))
385    }
386}
387
388impl PluginStorage for FilePluginStorage {
389    fn get(&self, key: &str) -> Option<Vec<u8>> {
390        std::fs::read(self.key_to_path(key)).ok()
391    }
392
393    fn set(&self, key: &str, data: &[u8]) {
394        let path = self.key_to_path(key);
395        if let Some(parent) = path.parent() {
396            let _ = std::fs::create_dir_all(parent);
397        }
398        let _ = std::fs::write(path, data);
399    }
400
401    fn delete(&self, key: &str) {
402        let _ = std::fs::remove_file(self.key_to_path(key));
403    }
404}
405
406/// File-backed [`PluginSecretStore`] implementation for native hosts.
407pub struct FilePluginSecretStore {
408    base_dir: PathBuf,
409}
410
411impl FilePluginSecretStore {
412    pub fn new(base_dir: PathBuf) -> Self {
413        let _ = std::fs::create_dir_all(&base_dir);
414        Self { base_dir }
415    }
416
417    fn key_to_path(&self, key: &str) -> PathBuf {
418        self.base_dir
419            .join(format!("{}.secret", sanitize_storage_key(key)))
420    }
421}
422
423impl PluginSecretStore for FilePluginSecretStore {
424    fn get(&self, key: &str) -> Option<String> {
425        std::fs::read_to_string(self.key_to_path(key)).ok()
426    }
427
428    fn set(&self, key: &str, value: &str) {
429        let path = self.key_to_path(key);
430        if let Some(parent) = path.parent() {
431            let _ = std::fs::create_dir_all(parent);
432        }
433        let _ = std::fs::write(path, value);
434    }
435
436    fn delete(&self, key: &str) {
437        let _ = std::fs::remove_file(self.key_to_path(key));
438    }
439}
440
441/// Trait for providing user-selected files to plugins.
442///
443/// On CLI, files come from command-line arguments (paths read into memory).
444/// On browser, files come from File input elements or drag-and-drop.
445/// Plugins request files by key name (e.g. "source_file", "dayone_export").
446pub trait FileProvider: Send + Sync {
447    /// Get file bytes by key name. Returns `None` if no file is available for that key.
448    fn get_file(&self, plugin_id: &str, key: &str) -> Option<Vec<u8>>;
449}
450
451/// No-op implementation of [`FileProvider`] — always returns `None`.
452pub struct NoopFileProvider;
453
454impl FileProvider for NoopFileProvider {
455    fn get_file(&self, _plugin_id: &str, _key: &str) -> Option<Vec<u8>> {
456        None
457    }
458}
459
460/// [`FileProvider`] backed by a pre-populated map.
461///
462/// Used by the CLI to pass files read from command-line arguments.
463pub struct MapFileProvider {
464    files: std::collections::HashMap<String, Vec<u8>>,
465}
466
467impl MapFileProvider {
468    pub fn new(files: std::collections::HashMap<String, Vec<u8>>) -> Self {
469        Self { files }
470    }
471}
472
473impl FileProvider for MapFileProvider {
474    fn get_file(&self, _plugin_id: &str, key: &str) -> Option<Vec<u8>> {
475        self.files.get(key).cloned()
476    }
477}
478
479/// No-op implementation of [`EventEmitter`] for plugins that don't emit events.
480pub struct NoopEventEmitter;
481
482impl EventEmitter for NoopEventEmitter {
483    fn emit(&self, _event_json: &str) {}
484}
485
486/// No-op websocket bridge for hosts that don't support plugin-managed transport.
487pub struct NoopWebSocketBridge;
488
489impl WebSocketBridge for NoopWebSocketBridge {
490    fn request(&self, _request_json: &str) -> Result<String, String> {
491        Ok(String::new())
492    }
493}
494
495/// No-op plugin command bridge for hosts that do not support plugin-to-plugin calls.
496pub struct NoopPluginCommandBridge;
497
498impl PluginCommandBridge for NoopPluginCommandBridge {
499    fn call(
500        &self,
501        _caller_plugin_id: &str,
502        _plugin_id: &str,
503        _command: &str,
504        _params: serde_json::Value,
505    ) -> Result<serde_json::Value, String> {
506        Err("Plugin command bridge is not available".to_string())
507    }
508}
509
510/// No-op runtime context provider for hosts without runtime context wiring.
511pub struct NoopRuntimeContextProvider;
512
513impl RuntimeContextProvider for NoopRuntimeContextProvider {
514    fn get_context(&self, _plugin_id: &str) -> serde_json::Value {
515        serde_json::json!({})
516    }
517}
518
519/// No-op namespace provider for hosts that don't support namespace operations.
520pub struct NoopNamespaceProvider;
521
522impl NamespaceProvider for NoopNamespaceProvider {
523    fn create_namespace(
524        &self,
525        _metadata: Option<&serde_json::Value>,
526    ) -> Result<NamespaceEntry, String> {
527        Err("Namespace operations are not available".to_string())
528    }
529
530    fn put_object(
531        &self,
532        _ns_id: &str,
533        _key: &str,
534        _bytes: &[u8],
535        _mime_type: &str,
536        _audience: Option<&str>,
537    ) -> Result<(), String> {
538        Err("Namespace operations are not available".to_string())
539    }
540    fn get_object(&self, _ns_id: &str, _key: &str) -> Result<Vec<u8>, String> {
541        Err("Namespace operations are not available".to_string())
542    }
543    fn delete_object(&self, _ns_id: &str, _key: &str) -> Result<(), String> {
544        Err("Namespace operations are not available".to_string())
545    }
546    fn list_objects(
547        &self,
548        _ns_id: &str,
549        _prefix: Option<&str>,
550        _limit: Option<u32>,
551        _offset: Option<u32>,
552    ) -> Result<Vec<NamespaceObjectMeta>, String> {
553        Err("Namespace operations are not available".to_string())
554    }
555    fn sync_audience(&self, _ns_id: &str, _audience: &str, _access: &str) -> Result<(), String> {
556        Err("Namespace operations are not available".to_string())
557    }
558    fn send_audience_email(
559        &self,
560        _ns_id: &str,
561        _audience: &str,
562        _subject: &str,
563        _reply_to: Option<&str>,
564    ) -> Result<serde_json::Value, String> {
565        Err("Namespace operations are not available".to_string())
566    }
567
568    fn get_objects_batch(&self, _ns_id: &str, _keys: &[String]) -> Result<BatchGetResult, String> {
569        Err("Namespace operations are not available".to_string())
570    }
571
572    fn list_namespaces(&self) -> Result<Vec<NamespaceEntry>, String> {
573        Err("Namespace operations are not available".to_string())
574    }
575}
576
577/// Trait for checking plugin permissions before allowing host function calls.
578///
579/// Implementations may check static config, prompt the user, or consult
580/// a session-level cache.
581pub trait PermissionChecker: Send + Sync {
582    /// Check if a plugin has permission for an action.
583    ///
584    /// Returns `Ok(())` if allowed, `Err(message)` if denied.
585    /// The `target` is context-dependent: file path, URL, command name, etc.
586    fn check_permission(
587        &self,
588        plugin_id: &str,
589        permission_type: PermissionType,
590        target: &str,
591    ) -> Result<(), String>;
592}
593
594/// Context shared with host functions via Extism's `UserData` mechanism.
595///
596/// Provides guest plugins with controlled access to the workspace filesystem,
597/// persistent storage, and event dispatch.
598pub struct HostContext {
599    /// Type-erased async filesystem for workspace file access.
600    pub fs: Arc<dyn AsyncFileSystem>,
601    /// Persistent storage for plugin state (CRDT snapshots, etc.).
602    pub storage: Arc<dyn PluginStorage>,
603    /// Persistent storage for plugin secrets (tokens, API keys).
604    pub secret_store: Arc<dyn PluginSecretStore>,
605    /// Event emitter for sync events.
606    pub event_emitter: Arc<dyn EventEmitter>,
607    /// Which plugin this context belongs to.
608    pub plugin_id: String,
609    /// Whether the plugin ID has been set from a guest manifest and should not be overwritten.
610    pub plugin_id_locked: bool,
611    /// Permission checker (None = deny all).
612    pub permission_checker: Option<Arc<dyn PermissionChecker>>,
613    /// Provider of user-selected files (e.g. from CLI args or browser file picker).
614    pub file_provider: Arc<dyn FileProvider>,
615    /// WebSocket bridge for plugin-managed sync transport.
616    pub ws_bridge: Arc<dyn WebSocketBridge>,
617    /// Host-mediated plugin-to-plugin command bridge.
618    pub plugin_command_bridge: Arc<dyn PluginCommandBridge>,
619    /// Provider of generic runtime context for the caller plugin.
620    pub runtime_context_provider: Arc<dyn RuntimeContextProvider>,
621    /// Provider of namespace object operations (upload, delete, list, sync).
622    pub namespace_provider: Arc<dyn NamespaceProvider>,
623    /// Current cross-plugin command call depth (prevents infinite recursion).
624    pub plugin_command_depth: u32,
625    /// Maximum storage bytes per plugin (0 = unlimited). Default: 1 MiB.
626    pub storage_quota_bytes: u64,
627}
628
629/// Default plugin storage quota: 1 MiB.
630pub const DEFAULT_STORAGE_QUOTA_BYTES: u64 = 1024 * 1024;
631
632impl HostContext {
633    /// Create a context with just a filesystem (backwards compatible).
634    pub fn with_fs(fs: Arc<dyn AsyncFileSystem>) -> Self {
635        Self {
636            fs,
637            storage: Arc::new(NoopStorage),
638            secret_store: Arc::new(NoopSecretStore),
639            event_emitter: Arc::new(NoopEventEmitter),
640            plugin_id: String::new(),
641            plugin_id_locked: false,
642            permission_checker: Some(Arc::new(DenyAllPermissionChecker)),
643            file_provider: Arc::new(NoopFileProvider),
644            ws_bridge: Arc::new(NoopWebSocketBridge),
645            plugin_command_bridge: Arc::new(NoopPluginCommandBridge),
646            runtime_context_provider: Arc::new(NoopRuntimeContextProvider),
647            namespace_provider: Arc::new(NoopNamespaceProvider),
648            plugin_command_depth: 0,
649            storage_quota_bytes: DEFAULT_STORAGE_QUOTA_BYTES,
650        }
651    }
652
653    /// Check a permission, returning an Extism error if denied.
654    fn check_perm(&self, perm: PermissionType, target: &str) -> Result<(), ExtismError> {
655        if let Some(checker) = &self.permission_checker {
656            checker
657                .check_permission(&self.plugin_id, perm, target)
658                .map_err(|msg| ExtismError::msg(msg))
659        } else {
660            Err(ExtismError::msg(
661                "Permission checker is not configured for this plugin host context",
662            ))
663        }
664    }
665
666    /// Validate HTTP header names and values to prevent header injection.
667    ///
668    /// Rejects headers containing newlines, null bytes, or carriage returns.
669    fn validate_http_headers(
670        headers: &std::collections::HashMap<String, String>,
671    ) -> Result<(), ExtismError> {
672        for (name, value) in headers {
673            if name.contains('\n')
674                || name.contains('\r')
675                || name.contains('\0')
676                || value.contains('\n')
677                || value.contains('\r')
678                || value.contains('\0')
679            {
680                return Err(ExtismError::msg(format!(
681                    "Invalid HTTP header: name or value contains forbidden characters (header: '{name}')"
682                )));
683            }
684        }
685        Ok(())
686    }
687
688    /// Validate and canonicalize a file path to prevent directory traversal.
689    ///
690    /// Rejects paths containing `..` components that could escape the workspace.
691    /// Returns the cleaned path string suitable for passing to the filesystem.
692    fn validate_file_path(path: &str) -> Result<String, ExtismError> {
693        let normalized = path.replace('\\', "/");
694        for component in normalized.split('/') {
695            if component == ".." {
696                return Err(ExtismError::msg(format!(
697                    "Path traversal not allowed: '{path}'"
698                )));
699            }
700        }
701        Ok(path.to_string())
702    }
703
704    fn storage_key(&self, key: &str) -> String {
705        if self.plugin_id.is_empty() {
706            key.to_string()
707        } else {
708            format!("{}:{}", self.plugin_id, key)
709        }
710    }
711
712    fn secret_key(&self, key: &str) -> String {
713        self.storage_key(key)
714    }
715}
716
717// SAFETY: HostContext only contains Arc<dyn Trait> values which require
718// Send + Sync on native targets.
719unsafe impl Send for HostContext {}
720unsafe impl Sync for HostContext {}
721
722/// Register all host functions on an Extism `PluginBuilder`.
723///
724/// The builder is consumed and returned with host functions attached.
725pub fn register_host_functions(
726    builder: extism::PluginBuilder<'_>,
727    user_data: UserData<HostContext>,
728) -> extism::PluginBuilder<'_> {
729    builder
730        .with_function(
731            "host_log",
732            [ValType::I64],
733            [ValType::I64],
734            user_data.clone(),
735            host_log,
736        )
737        .with_function(
738            "host_read_file",
739            [ValType::I64],
740            [ValType::I64],
741            user_data.clone(),
742            host_read_file,
743        )
744        .with_function(
745            "host_read_binary",
746            [ValType::I64],
747            [ValType::I64],
748            user_data.clone(),
749            host_read_binary,
750        )
751        .with_function(
752            "host_list_files",
753            [ValType::I64],
754            [ValType::I64],
755            user_data.clone(),
756            host_list_files,
757        )
758        .with_function(
759            "host_list_dir",
760            [ValType::I64],
761            [ValType::I64],
762            user_data.clone(),
763            host_list_dir,
764        )
765        .with_function(
766            "host_workspace_file_set",
767            [ValType::I64],
768            [ValType::I64],
769            user_data.clone(),
770            host_workspace_file_set,
771        )
772        .with_function(
773            "host_file_exists",
774            [ValType::I64],
775            [ValType::I64],
776            user_data.clone(),
777            host_file_exists,
778        )
779        .with_function(
780            "host_file_metadata",
781            [ValType::I64],
782            [ValType::I64],
783            user_data.clone(),
784            host_file_metadata,
785        )
786        .with_function(
787            "host_write_file",
788            [ValType::I64],
789            [ValType::I64],
790            user_data.clone(),
791            host_write_file,
792        )
793        .with_function(
794            "host_delete_file",
795            [ValType::I64],
796            [ValType::I64],
797            user_data.clone(),
798            host_delete_file,
799        )
800        .with_function(
801            "host_write_binary",
802            [ValType::I64],
803            [ValType::I64],
804            user_data.clone(),
805            host_write_binary,
806        )
807        .with_function(
808            "host_emit_event",
809            [ValType::I64],
810            [ValType::I64],
811            user_data.clone(),
812            host_emit_event,
813        )
814        .with_function(
815            "host_storage_get",
816            [ValType::I64],
817            [ValType::I64],
818            user_data.clone(),
819            host_storage_get,
820        )
821        .with_function(
822            "host_storage_set",
823            [ValType::I64],
824            [ValType::I64],
825            user_data.clone(),
826            host_storage_set,
827        )
828        .with_function(
829            "host_secret_get",
830            [ValType::I64],
831            [ValType::I64],
832            user_data.clone(),
833            host_secret_get,
834        )
835        .with_function(
836            "host_secret_set",
837            [ValType::I64],
838            [ValType::I64],
839            user_data.clone(),
840            host_secret_set,
841        )
842        .with_function(
843            "host_secret_delete",
844            [ValType::I64],
845            [ValType::I64],
846            user_data.clone(),
847            host_secret_delete,
848        )
849        .with_function(
850            "host_get_timestamp",
851            [ValType::I64],
852            [ValType::I64],
853            user_data.clone(),
854            host_get_timestamp,
855        )
856        .with_function(
857            "host_get_now",
858            [ValType::I64],
859            [ValType::I64],
860            user_data.clone(),
861            host_get_now,
862        )
863        .with_function(
864            "host_http_request",
865            [ValType::I64],
866            [ValType::I64],
867            user_data.clone(),
868            host_http_request,
869        )
870        .with_function(
871            "host_run_wasi_module",
872            [ValType::I64],
873            [ValType::I64],
874            user_data.clone(),
875            host_run_wasi_module,
876        )
877        .with_function(
878            "host_request_file",
879            [ValType::I64],
880            [ValType::I64],
881            user_data.clone(),
882            host_request_file,
883        )
884        .with_function(
885            "host_plugin_command",
886            [ValType::I64],
887            [ValType::I64],
888            user_data.clone(),
889            host_plugin_command,
890        )
891        .with_function(
892            "host_get_runtime_context",
893            [ValType::I64],
894            [ValType::I64],
895            user_data.clone(),
896            host_get_runtime_context,
897        )
898        .with_function(
899            "host_namespace_put_object",
900            [ValType::I64],
901            [ValType::I64],
902            user_data.clone(),
903            host_namespace_put_object,
904        )
905        .with_function(
906            "host_namespace_delete_object",
907            [ValType::I64],
908            [ValType::I64],
909            user_data.clone(),
910            host_namespace_delete_object,
911        )
912        .with_function(
913            "host_namespace_get_object",
914            [ValType::I64],
915            [ValType::I64],
916            user_data.clone(),
917            host_namespace_get_object,
918        )
919        .with_function(
920            "host_namespace_get_objects_batch",
921            [ValType::I64],
922            [ValType::I64],
923            user_data.clone(),
924            host_namespace_get_objects_batch,
925        )
926        .with_function(
927            "host_namespace_list_objects",
928            [ValType::I64],
929            [ValType::I64],
930            user_data.clone(),
931            host_namespace_list_objects,
932        )
933        .with_function(
934            "host_namespace_list",
935            [ValType::I64],
936            [ValType::I64],
937            user_data.clone(),
938            host_namespace_list,
939        )
940        .with_function(
941            "host_namespace_create",
942            [ValType::I64],
943            [ValType::I64],
944            user_data.clone(),
945            host_namespace_create,
946        )
947        .with_function(
948            "host_namespace_sync_audience",
949            [ValType::I64],
950            [ValType::I64],
951            user_data.clone(),
952            host_namespace_sync_audience,
953        )
954        .with_function(
955            "host_namespace_send_email",
956            [ValType::I64],
957            [ValType::I64],
958            user_data.clone(),
959            host_namespace_send_email,
960        )
961        .with_function(
962            "host_ws_request",
963            [ValType::I64],
964            [ValType::I64],
965            user_data.clone(),
966            host_ws_request,
967        )
968        .with_function(
969            "host_hash_file",
970            [ValType::I64],
971            [ValType::I64],
972            user_data.clone(),
973            host_hash_file,
974        )
975        .with_function(
976            "host_proxy_request",
977            [ValType::I64],
978            [ValType::I64],
979            user_data,
980            host_proxy_request,
981        )
982}
983
984/// Host function: `host_log(input: {level, message}) -> ""`
985///
986/// Logs a message via the `log` crate at the specified level.
987fn host_log(
988    plugin: &mut CurrentPlugin,
989    inputs: &[Val],
990    outputs: &mut [Val],
991    _user_data: UserData<HostContext>,
992) -> Result<(), ExtismError> {
993    let input: String = plugin.memory_get_val(&inputs[0])?;
994
995    #[derive(serde::Deserialize)]
996    struct LogInput {
997        level: String,
998        message: String,
999    }
1000
1001    let parsed: LogInput = serde_json::from_str(&input)
1002        .map_err(|e| ExtismError::msg(format!("host_log: invalid input: {e}")))?;
1003
1004    match parsed.level.as_str() {
1005        "error" => log::error!("[extism-plugin] {}", parsed.message),
1006        "warn" => log::warn!("[extism-plugin] {}", parsed.message),
1007        "info" => log::info!("[extism-plugin] {}", parsed.message),
1008        "debug" => log::debug!("[extism-plugin] {}", parsed.message),
1009        _ => log::trace!("[extism-plugin] {}", parsed.message),
1010    }
1011
1012    plugin.memory_set_val(&mut outputs[0], "")?;
1013    Ok(())
1014}
1015
1016/// Host function: `host_read_file(input: {path}) -> file content string or {"error": "..."}`
1017///
1018/// Reads a workspace file and returns its content.
1019/// Returns a JSON error object instead of trapping on I/O or permission errors,
1020/// so the guest can handle missing files gracefully via `.ok()`.
1021fn host_read_file(
1022    plugin: &mut CurrentPlugin,
1023    inputs: &[Val],
1024    outputs: &mut [Val],
1025    user_data: UserData<HostContext>,
1026) -> Result<(), ExtismError> {
1027    let input: String = plugin.memory_get_val(&inputs[0])?;
1028
1029    #[derive(serde::Deserialize)]
1030    struct ReadInput {
1031        path: String,
1032    }
1033
1034    let parsed: ReadInput = serde_json::from_str(&input)
1035        .map_err(|e| ExtismError::msg(format!("host_read_file: invalid input: {e}")))?;
1036    let path = HostContext::validate_file_path(&parsed.path)?;
1037
1038    let ctx = user_data.get()?;
1039    let ctx = ctx
1040        .lock()
1041        .map_err(|e| ExtismError::msg(format!("host_read_file: lock: {e}")))?;
1042
1043    if let Err(e) = ctx.check_perm(PermissionType::ReadFiles, &path) {
1044        let err = serde_json::json!({ "error": e.to_string() }).to_string();
1045        plugin.memory_set_val(&mut outputs[0], err.as_str())?;
1046        return Ok(());
1047    }
1048
1049    match futures_lite::future::block_on(ctx.fs.read_to_string(Path::new(&path))) {
1050        Ok(content) => {
1051            plugin.memory_set_val(&mut outputs[0], content.as_str())?;
1052        }
1053        Err(e) => {
1054            let err = serde_json::json!({ "error": format!("host_read_file: {e}") }).to_string();
1055            plugin.memory_set_val(&mut outputs[0], err.as_str())?;
1056        }
1057    }
1058    Ok(())
1059}
1060
1061/// Host function: `host_read_binary(input: {path}) -> {data: base64}`
1062///
1063/// Reads a workspace file as raw bytes.
1064fn host_read_binary(
1065    plugin: &mut CurrentPlugin,
1066    inputs: &[Val],
1067    outputs: &mut [Val],
1068    user_data: UserData<HostContext>,
1069) -> Result<(), ExtismError> {
1070    use base64::Engine;
1071
1072    let input: String = plugin.memory_get_val(&inputs[0])?;
1073
1074    #[derive(serde::Deserialize)]
1075    struct ReadInput {
1076        path: String,
1077    }
1078
1079    let parsed: ReadInput = serde_json::from_str(&input)
1080        .map_err(|e| ExtismError::msg(format!("host_read_binary: invalid input: {e}")))?;
1081    let path = HostContext::validate_file_path(&parsed.path)?;
1082
1083    let ctx = user_data.get()?;
1084    let ctx = ctx
1085        .lock()
1086        .map_err(|e| ExtismError::msg(format!("host_read_binary: lock: {e}")))?;
1087
1088    if let Err(e) = ctx.check_perm(PermissionType::ReadFiles, &path) {
1089        let err = serde_json::json!({ "error": e.to_string() }).to_string();
1090        plugin.memory_set_val(&mut outputs[0], err.as_str())?;
1091        return Ok(());
1092    }
1093
1094    match futures_lite::future::block_on(ctx.fs.read_binary(Path::new(&path))) {
1095        Ok(bytes) => {
1096            let json = serde_json::json!({
1097                "data": base64::engine::general_purpose::STANDARD.encode(&bytes)
1098            })
1099            .to_string();
1100            plugin.memory_set_val(&mut outputs[0], json.as_str())?;
1101        }
1102        Err(e) => {
1103            let err = serde_json::json!({ "error": format!("host_read_binary: {e}") }).to_string();
1104            plugin.memory_set_val(&mut outputs[0], err.as_str())?;
1105        }
1106    }
1107    Ok(())
1108}
1109
1110/// Host function: `host_list_dir(input: {path}) -> string[] JSON`
1111///
1112/// Lists direct children of a directory (non-recursive, single level).
1113/// Returns paths as strings. This is much cheaper than `host_list_files`
1114/// for large workspaces because it never descends into subdirectories.
1115fn host_list_dir(
1116    plugin: &mut CurrentPlugin,
1117    inputs: &[Val],
1118    outputs: &mut [Val],
1119    user_data: UserData<HostContext>,
1120) -> Result<(), ExtismError> {
1121    let input: String = plugin.memory_get_val(&inputs[0])?;
1122
1123    #[derive(serde::Deserialize)]
1124    struct ListDirInput {
1125        path: String,
1126    }
1127
1128    let parsed: ListDirInput = serde_json::from_str(&input)
1129        .map_err(|e| ExtismError::msg(format!("host_list_dir: invalid input: {e}")))?;
1130    let dir_path = HostContext::validate_file_path(&parsed.path)?;
1131
1132    let ctx = user_data.get()?;
1133    let ctx = ctx
1134        .lock()
1135        .map_err(|e| ExtismError::msg(format!("host_list_dir: lock: {e}")))?;
1136    if let Err(e) = ctx.check_perm(PermissionType::ReadFiles, &dir_path) {
1137        let err = serde_json::json!({ "error": e.to_string() }).to_string();
1138        plugin.memory_set_val(&mut outputs[0], err.as_str())?;
1139        return Ok(());
1140    }
1141    let files = match futures_lite::future::block_on(ctx.fs.list_files(Path::new(&dir_path))) {
1142        Ok(files) => files,
1143        Err(e) => {
1144            let err = serde_json::json!({ "error": format!("host_list_dir: {e}") }).to_string();
1145            plugin.memory_set_val(&mut outputs[0], err.as_str())?;
1146            return Ok(());
1147        }
1148    };
1149
1150    let file_strings: Vec<String> = files
1151        .iter()
1152        .map(|p| p.to_string_lossy().to_string())
1153        .collect();
1154    let json = serde_json::to_string(&file_strings)
1155        .map_err(|e| ExtismError::msg(format!("host_list_dir: serialize: {e}")))?;
1156
1157    plugin.memory_set_val(&mut outputs[0], json.as_str())?;
1158    Ok(())
1159}
1160
1161/// Host function: `host_list_files(input: {prefix}) -> string[] JSON`
1162///
1163/// Lists files under a given prefix in the workspace.
1164fn host_list_files(
1165    plugin: &mut CurrentPlugin,
1166    inputs: &[Val],
1167    outputs: &mut [Val],
1168    user_data: UserData<HostContext>,
1169) -> Result<(), ExtismError> {
1170    let input: String = plugin.memory_get_val(&inputs[0])?;
1171
1172    #[derive(serde::Deserialize)]
1173    struct ListInput {
1174        prefix: String,
1175    }
1176
1177    let parsed: ListInput = serde_json::from_str(&input)
1178        .map_err(|e| ExtismError::msg(format!("host_list_files: invalid input: {e}")))?;
1179    let prefix = HostContext::validate_file_path(&parsed.prefix)?;
1180
1181    let ctx = user_data.get()?;
1182    let ctx = ctx
1183        .lock()
1184        .map_err(|e| ExtismError::msg(format!("host_list_files: lock: {e}")))?;
1185    if let Err(e) = ctx.check_perm(PermissionType::ReadFiles, &prefix) {
1186        let err = serde_json::json!({ "error": e.to_string() }).to_string();
1187        plugin.memory_set_val(&mut outputs[0], err.as_str())?;
1188        return Ok(());
1189    }
1190    let files =
1191        match futures_lite::future::block_on(ctx.fs.list_all_files_recursive(Path::new(&prefix))) {
1192            Ok(files) => files,
1193            Err(e) => {
1194                let err =
1195                    serde_json::json!({ "error": format!("host_list_files: {e}") }).to_string();
1196                plugin.memory_set_val(&mut outputs[0], err.as_str())?;
1197                return Ok(());
1198            }
1199        };
1200
1201    let file_strings: Vec<String> = files
1202        .iter()
1203        .map(|p| p.to_string_lossy().to_string())
1204        .collect();
1205    let json = serde_json::to_string(&file_strings)
1206        .map_err(|e| ExtismError::msg(format!("host_list_files: serialize: {e}")))?;
1207
1208    plugin.memory_set_val(&mut outputs[0], json.as_str())?;
1209    Ok(())
1210}
1211
1212/// Host function: `host_workspace_file_set({}) -> string[] JSON`
1213///
1214/// Returns the canonical workspace-relative file set for the current
1215/// workspace, including reachable markdown files and declared attachments.
1216fn host_workspace_file_set(
1217    plugin: &mut CurrentPlugin,
1218    _inputs: &[Val],
1219    outputs: &mut [Val],
1220    user_data: UserData<HostContext>,
1221) -> Result<(), ExtismError> {
1222    // Inner function returns Result so we can use `?` for control flow, then
1223    // the outer function converts errors to output strings instead of WASM
1224    // traps so the guest can handle them gracefully.
1225    fn inner(user_data: &UserData<HostContext>) -> Result<Vec<String>, String> {
1226        let ctx = user_data
1227            .get()
1228            .map_err(|e| format!("host_workspace_file_set: user_data: {e}"))?;
1229        let ctx = ctx
1230            .lock()
1231            .map_err(|e| format!("host_workspace_file_set: lock: {e}"))?;
1232        let runtime = ctx.runtime_context_provider.get_context(&ctx.plugin_id);
1233        let workspace_path = runtime
1234            .get("current_workspace")
1235            .and_then(|value| value.as_object())
1236            .and_then(|workspace| workspace.get("path"))
1237            .and_then(|value| value.as_str())
1238            .filter(|value| !value.trim().is_empty())
1239            .ok_or("host_workspace_file_set: missing current_workspace.path")?;
1240
1241        ctx.check_perm(PermissionType::ReadFiles, workspace_path)
1242            .map_err(|e| e.to_string())?;
1243
1244        let workspace = diaryx_core::workspace::Workspace::new(ctx.fs.as_ref());
1245        let workspace_path = Path::new(workspace_path);
1246        let root_index = if workspace_path
1247            .extension()
1248            .is_some_and(|extension| extension == "md")
1249        {
1250            workspace_path.to_path_buf()
1251        } else {
1252            futures_lite::future::block_on(workspace.find_root_index_in_dir(workspace_path))
1253                .map_err(|e| format!("host_workspace_file_set: {e}"))?
1254                .ok_or("host_workspace_file_set: workspace root index not found")?
1255        };
1256
1257        futures_lite::future::block_on(workspace.collect_workspace_file_set(&root_index))
1258            .map_err(|e| format!("host_workspace_file_set: {e}"))
1259    }
1260
1261    match inner(&user_data) {
1262        Ok(files) => {
1263            let json = serde_json::to_string(&files).map_err(|e| {
1264                ExtismError::msg(format!("host_workspace_file_set: serialize: {e}"))
1265            })?;
1266            plugin.memory_set_val(&mut outputs[0], json.as_str())?;
1267        }
1268        Err(msg) => {
1269            // Return the error as the output string. The guest SDK will fail
1270            // to parse it as JSON and surface it as Result::Err.
1271            plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
1272        }
1273    }
1274    Ok(())
1275}
1276
1277/// Host function: `host_file_exists(input: {path}) -> bool JSON`
1278///
1279/// Checks if a file exists in the workspace.
1280fn host_file_exists(
1281    plugin: &mut CurrentPlugin,
1282    inputs: &[Val],
1283    outputs: &mut [Val],
1284    user_data: UserData<HostContext>,
1285) -> Result<(), ExtismError> {
1286    let input: String = plugin.memory_get_val(&inputs[0])?;
1287
1288    #[derive(serde::Deserialize)]
1289    struct ExistsInput {
1290        path: String,
1291    }
1292
1293    let parsed: ExistsInput = serde_json::from_str(&input)
1294        .map_err(|e| ExtismError::msg(format!("host_file_exists: invalid input: {e}")))?;
1295    let path = HostContext::validate_file_path(&parsed.path)?;
1296
1297    let ctx = user_data.get()?;
1298    let ctx = ctx
1299        .lock()
1300        .map_err(|e| ExtismError::msg(format!("host_file_exists: lock: {e}")))?;
1301    // Permission errors return `false` (effectively "not visible") rather than
1302    // trapping, mirroring the existing graceful pattern in host_hash_file.
1303    if ctx.check_perm(PermissionType::ReadFiles, &path).is_err() {
1304        plugin.memory_set_val(&mut outputs[0], "false")?;
1305        return Ok(());
1306    }
1307    let exists = futures_lite::future::block_on(ctx.fs.exists(Path::new(&path)));
1308
1309    let json = serde_json::to_string(&exists)
1310        .map_err(|e| ExtismError::msg(format!("host_file_exists: serialize: {e}")))?;
1311
1312    plugin.memory_set_val(&mut outputs[0], json.as_str())?;
1313    Ok(())
1314}
1315
1316/// Host function: `host_file_metadata(input: {path}) -> {exists, size_bytes?, modified_at_ms?}`
1317///
1318/// Returns lightweight metadata for a workspace file without reading its bytes.
1319fn host_file_metadata(
1320    plugin: &mut CurrentPlugin,
1321    inputs: &[Val],
1322    outputs: &mut [Val],
1323    user_data: UserData<HostContext>,
1324) -> Result<(), ExtismError> {
1325    let input: String = plugin.memory_get_val(&inputs[0])?;
1326
1327    #[derive(serde::Deserialize)]
1328    struct MetadataInput {
1329        path: String,
1330    }
1331
1332    let parsed: MetadataInput = serde_json::from_str(&input)
1333        .map_err(|e| ExtismError::msg(format!("host_file_metadata: invalid input: {e}")))?;
1334    let validated_path = HostContext::validate_file_path(&parsed.path)?;
1335
1336    let not_found = serde_json::json!({
1337        "exists": false,
1338        "size_bytes": serde_json::Value::Null,
1339        "modified_at_ms": serde_json::Value::Null,
1340    })
1341    .to_string();
1342
1343    let ctx = user_data.get()?;
1344    let ctx = ctx
1345        .lock()
1346        .map_err(|e| ExtismError::msg(format!("host_file_metadata: lock: {e}")))?;
1347    // Permission or filesystem errors return "not found" rather than trapping.
1348    if ctx
1349        .check_perm(PermissionType::ReadFiles, &validated_path)
1350        .is_err()
1351    {
1352        plugin.memory_set_val(&mut outputs[0], not_found.as_str())?;
1353        return Ok(());
1354    }
1355    let path = Path::new(&validated_path);
1356    let exists = futures_lite::future::block_on(ctx.fs.exists(path));
1357    let json = if exists {
1358        let size_bytes = futures_lite::future::block_on(ctx.fs.get_file_size(path));
1359        let modified_at_ms = futures_lite::future::block_on(ctx.fs.get_modified_time(path));
1360        serde_json::json!({
1361            "exists": true,
1362            "size_bytes": size_bytes,
1363            "modified_at_ms": modified_at_ms,
1364        })
1365        .to_string()
1366    } else {
1367        not_found
1368    };
1369
1370    plugin.memory_set_val(&mut outputs[0], json.as_str())?;
1371    Ok(())
1372}
1373
1374/// Host function: `host_write_file(input: {path, content}) -> ""`
1375///
1376/// Writes a text file to the workspace.
1377fn host_write_file(
1378    plugin: &mut CurrentPlugin,
1379    inputs: &[Val],
1380    outputs: &mut [Val],
1381    user_data: UserData<HostContext>,
1382) -> Result<(), ExtismError> {
1383    let input: String = plugin.memory_get_val(&inputs[0])?;
1384
1385    #[derive(serde::Deserialize)]
1386    struct WriteInput {
1387        path: String,
1388        content: String,
1389    }
1390
1391    let parsed: WriteInput = serde_json::from_str(&input)
1392        .map_err(|e| ExtismError::msg(format!("host_write_file: invalid input: {e}")))?;
1393    let path = HostContext::validate_file_path(&parsed.path)?;
1394
1395    let ctx = user_data.get()?;
1396    let ctx = ctx
1397        .lock()
1398        .map_err(|e| ExtismError::msg(format!("host_write_file: lock: {e}")))?;
1399    let exists = futures_lite::future::block_on(ctx.fs.exists(Path::new(&path)));
1400    let perm = if exists {
1401        PermissionType::EditFiles
1402    } else {
1403        PermissionType::CreateFiles
1404    };
1405    if let Err(e) = ctx.check_perm(perm, &path) {
1406        plugin.memory_set_val(&mut outputs[0], e.to_string().as_str())?;
1407        return Ok(());
1408    }
1409    // Return filesystem errors as a string rather than propagating them as
1410    // ExtismError.  An ExtismError causes a WASM trap that aborts the entire
1411    // guest call — the guest code never gets a chance to handle it.  By
1412    // returning the error message in the output the guest SDK can surface it
1413    // as a normal `Result::Err` that callers can recover from.
1414    if let Err(e) =
1415        futures_lite::future::block_on(ctx.fs.write_file(Path::new(&path), &parsed.content))
1416    {
1417        let msg = format!("host_write_file: {e}");
1418        plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
1419        return Ok(());
1420    }
1421
1422    plugin.memory_set_val(&mut outputs[0], "")?;
1423    Ok(())
1424}
1425
1426/// Host function: `host_delete_file(input: {path}) -> "" | error`
1427///
1428/// Deletes a file from the workspace.
1429/// Returns an empty string on success, or an error message on failure.
1430fn host_delete_file(
1431    plugin: &mut CurrentPlugin,
1432    inputs: &[Val],
1433    outputs: &mut [Val],
1434    user_data: UserData<HostContext>,
1435) -> Result<(), ExtismError> {
1436    let input: String = plugin.memory_get_val(&inputs[0])?;
1437
1438    #[derive(serde::Deserialize)]
1439    struct DeleteInput {
1440        path: String,
1441    }
1442
1443    let parsed: DeleteInput = serde_json::from_str(&input)
1444        .map_err(|e| ExtismError::msg(format!("host_delete_file: invalid input: {e}")))?;
1445    let path = HostContext::validate_file_path(&parsed.path)?;
1446
1447    let ctx = user_data.get()?;
1448    let ctx = ctx
1449        .lock()
1450        .map_err(|e| ExtismError::msg(format!("host_delete_file: lock: {e}")))?;
1451    if let Err(e) = ctx.check_perm(PermissionType::DeleteFiles, &path) {
1452        plugin.memory_set_val(&mut outputs[0], e.to_string().as_str())?;
1453        return Ok(());
1454    }
1455    // Return filesystem errors as a recoverable string — see host_write_file
1456    // comment for rationale.
1457    if let Err(e) = futures_lite::future::block_on(ctx.fs.delete_file(Path::new(&path))) {
1458        let msg = format!("host_delete_file: {e}");
1459        plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
1460        return Ok(());
1461    }
1462
1463    plugin.memory_set_val(&mut outputs[0], "")?;
1464    Ok(())
1465}
1466
1467/// Host function: `host_write_binary(input: {path, content}) -> "" | error`
1468///
1469/// Writes binary content (base64-encoded) to a file.
1470/// Returns an empty string on success, or an error message on failure.
1471fn host_write_binary(
1472    plugin: &mut CurrentPlugin,
1473    inputs: &[Val],
1474    outputs: &mut [Val],
1475    user_data: UserData<HostContext>,
1476) -> Result<(), ExtismError> {
1477    use base64::Engine;
1478
1479    let input: String = plugin.memory_get_val(&inputs[0])?;
1480
1481    #[derive(serde::Deserialize)]
1482    struct WriteBinaryInput {
1483        path: String,
1484        content: String, // base64-encoded
1485    }
1486
1487    let parsed: WriteBinaryInput = serde_json::from_str(&input)
1488        .map_err(|e| ExtismError::msg(format!("host_write_binary: invalid input: {e}")))?;
1489
1490    let bytes = base64::engine::general_purpose::STANDARD
1491        .decode(&parsed.content)
1492        .map_err(|e| ExtismError::msg(format!("host_write_binary: base64 decode: {e}")))?;
1493
1494    let path = HostContext::validate_file_path(&parsed.path)?;
1495
1496    let ctx = user_data.get()?;
1497    let ctx = ctx
1498        .lock()
1499        .map_err(|e| ExtismError::msg(format!("host_write_binary: lock: {e}")))?;
1500    let exists = futures_lite::future::block_on(ctx.fs.exists(Path::new(&path)));
1501    let perm = if exists {
1502        PermissionType::EditFiles
1503    } else {
1504        PermissionType::CreateFiles
1505    };
1506    if let Err(e) = ctx.check_perm(perm, &path) {
1507        plugin.memory_set_val(&mut outputs[0], e.to_string().as_str())?;
1508        return Ok(());
1509    }
1510    // Return filesystem errors as a recoverable string — see host_write_file
1511    // comment for rationale.
1512    if let Err(e) = futures_lite::future::block_on(ctx.fs.write_binary(Path::new(&path), &bytes)) {
1513        let msg = format!("host_write_binary: {e}");
1514        plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
1515        return Ok(());
1516    }
1517
1518    plugin.memory_set_val(&mut outputs[0], "")?;
1519    Ok(())
1520}
1521
1522/// Host function: `host_emit_event(input: event_json) -> ""`
1523///
1524/// Emits a sync event to the host application.
1525fn host_emit_event(
1526    plugin: &mut CurrentPlugin,
1527    inputs: &[Val],
1528    outputs: &mut [Val],
1529    user_data: UserData<HostContext>,
1530) -> Result<(), ExtismError> {
1531    let event_json: String = plugin.memory_get_val(&inputs[0])?;
1532
1533    let ctx = user_data.get()?;
1534    let ctx = ctx
1535        .lock()
1536        .map_err(|e| ExtismError::msg(format!("host_emit_event: lock: {e}")))?;
1537    ctx.event_emitter.emit(&event_json);
1538
1539    plugin.memory_set_val(&mut outputs[0], "")?;
1540    Ok(())
1541}
1542
1543/// Host function: `host_storage_get(input: {key}) -> {data: base64} or ""`
1544///
1545/// Loads persisted state by key.
1546fn host_storage_get(
1547    plugin: &mut CurrentPlugin,
1548    inputs: &[Val],
1549    outputs: &mut [Val],
1550    user_data: UserData<HostContext>,
1551) -> Result<(), ExtismError> {
1552    use base64::Engine;
1553
1554    let input: String = plugin.memory_get_val(&inputs[0])?;
1555
1556    #[derive(serde::Deserialize)]
1557    struct StorageGetInput {
1558        key: String,
1559    }
1560
1561    let parsed: StorageGetInput = serde_json::from_str(&input)
1562        .map_err(|e| ExtismError::msg(format!("host_storage_get: invalid input: {e}")))?;
1563
1564    let ctx = user_data.get()?;
1565    let ctx = ctx
1566        .lock()
1567        .map_err(|e| ExtismError::msg(format!("host_storage_get: lock: {e}")))?;
1568    // Permission errors return empty (key not found) rather than trapping.
1569    if ctx
1570        .check_perm(PermissionType::PluginStorage, &parsed.key)
1571        .is_err()
1572    {
1573        plugin.memory_set_val(&mut outputs[0], "")?;
1574        return Ok(());
1575    }
1576    let storage_key = ctx.storage_key(&parsed.key);
1577
1578    let result = match ctx.storage.get(&storage_key) {
1579        Some(data) => {
1580            let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
1581            serde_json::json!({ "data": encoded }).to_string()
1582        }
1583        None => String::new(),
1584    };
1585
1586    plugin.memory_set_val(&mut outputs[0], result.as_str())?;
1587    Ok(())
1588}
1589
1590/// Host function: `host_storage_set(input: {key, data}) -> ""`
1591///
1592/// Persists state by key (data is base64-encoded).
1593fn host_storage_set(
1594    plugin: &mut CurrentPlugin,
1595    inputs: &[Val],
1596    outputs: &mut [Val],
1597    user_data: UserData<HostContext>,
1598) -> Result<(), ExtismError> {
1599    use base64::Engine;
1600
1601    let input: String = plugin.memory_get_val(&inputs[0])?;
1602
1603    #[derive(serde::Deserialize)]
1604    struct StorageSetInput {
1605        key: String,
1606        data: String, // base64-encoded
1607    }
1608
1609    let parsed: StorageSetInput = serde_json::from_str(&input)
1610        .map_err(|e| ExtismError::msg(format!("host_storage_set: invalid input: {e}")))?;
1611
1612    let bytes = base64::engine::general_purpose::STANDARD
1613        .decode(&parsed.data)
1614        .map_err(|e| ExtismError::msg(format!("host_storage_set: base64 decode: {e}")))?;
1615
1616    let ctx = user_data.get()?;
1617    let ctx = ctx
1618        .lock()
1619        .map_err(|e| ExtismError::msg(format!("host_storage_set: lock: {e}")))?;
1620    // Permission errors return an error string rather than trapping.
1621    if let Err(e) = ctx.check_perm(PermissionType::PluginStorage, &parsed.key) {
1622        let msg = format!("host_storage_set: {e}");
1623        plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
1624        return Ok(());
1625    }
1626    if ctx.storage_quota_bytes > 0 && bytes.len() as u64 > ctx.storage_quota_bytes {
1627        let msg = format!(
1628            "host_storage_set: data size ({} bytes) exceeds plugin storage quota ({} bytes)",
1629            bytes.len(),
1630            ctx.storage_quota_bytes
1631        );
1632        plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
1633        return Ok(());
1634    }
1635    let storage_key = ctx.storage_key(&parsed.key);
1636    ctx.storage.set(&storage_key, &bytes);
1637
1638    plugin.memory_set_val(&mut outputs[0], "")?;
1639    Ok(())
1640}
1641
1642/// Host function: `host_secret_get(input: {key}) -> {value: string} or ""`
1643///
1644/// Loads a secret value by key.
1645fn host_secret_get(
1646    plugin: &mut CurrentPlugin,
1647    inputs: &[Val],
1648    outputs: &mut [Val],
1649    user_data: UserData<HostContext>,
1650) -> Result<(), ExtismError> {
1651    let input: String = plugin.memory_get_val(&inputs[0])?;
1652
1653    #[derive(serde::Deserialize)]
1654    struct SecretGetInput {
1655        key: String,
1656    }
1657
1658    let parsed: SecretGetInput = serde_json::from_str(&input)
1659        .map_err(|e| ExtismError::msg(format!("host_secret_get: invalid input: {e}")))?;
1660
1661    let ctx = user_data.get()?;
1662    let ctx = ctx
1663        .lock()
1664        .map_err(|e| ExtismError::msg(format!("host_secret_get: lock: {e}")))?;
1665    // Permission errors return empty (key not found) rather than trapping.
1666    if ctx
1667        .check_perm(PermissionType::PluginStorage, &parsed.key)
1668        .is_err()
1669    {
1670        plugin.memory_set_val(&mut outputs[0], "")?;
1671        return Ok(());
1672    }
1673    let secret_key = ctx.secret_key(&parsed.key);
1674
1675    let result = match ctx.secret_store.get(&secret_key) {
1676        Some(value) => serde_json::json!({ "value": value }).to_string(),
1677        None => String::new(),
1678    };
1679
1680    plugin.memory_set_val(&mut outputs[0], result.as_str())?;
1681    Ok(())
1682}
1683
1684/// Host function: `host_secret_set(input: {key, value}) -> ""`
1685///
1686/// Persists a secret value by key.
1687fn host_secret_set(
1688    plugin: &mut CurrentPlugin,
1689    inputs: &[Val],
1690    outputs: &mut [Val],
1691    user_data: UserData<HostContext>,
1692) -> Result<(), ExtismError> {
1693    let input: String = plugin.memory_get_val(&inputs[0])?;
1694
1695    #[derive(serde::Deserialize)]
1696    struct SecretSetInput {
1697        key: String,
1698        value: String,
1699    }
1700
1701    let parsed: SecretSetInput = serde_json::from_str(&input)
1702        .map_err(|e| ExtismError::msg(format!("host_secret_set: invalid input: {e}")))?;
1703
1704    let ctx = user_data.get()?;
1705    let ctx = ctx
1706        .lock()
1707        .map_err(|e| ExtismError::msg(format!("host_secret_set: lock: {e}")))?;
1708    // Permission errors return an error string rather than trapping.
1709    if let Err(e) = ctx.check_perm(PermissionType::PluginStorage, &parsed.key) {
1710        let msg = format!("host_secret_set: {e}");
1711        plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
1712        return Ok(());
1713    }
1714    let secret_key = ctx.secret_key(&parsed.key);
1715    ctx.secret_store.set(&secret_key, &parsed.value);
1716
1717    plugin.memory_set_val(&mut outputs[0], "")?;
1718    Ok(())
1719}
1720
1721/// Host function: `host_secret_delete(input: {key}) -> ""`
1722///
1723/// Deletes a secret value by key.
1724fn host_secret_delete(
1725    plugin: &mut CurrentPlugin,
1726    inputs: &[Val],
1727    outputs: &mut [Val],
1728    user_data: UserData<HostContext>,
1729) -> Result<(), ExtismError> {
1730    let input: String = plugin.memory_get_val(&inputs[0])?;
1731
1732    #[derive(serde::Deserialize)]
1733    struct SecretDeleteInput {
1734        key: String,
1735    }
1736
1737    let parsed: SecretDeleteInput = serde_json::from_str(&input)
1738        .map_err(|e| ExtismError::msg(format!("host_secret_delete: invalid input: {e}")))?;
1739
1740    let ctx = user_data.get()?;
1741    let ctx = ctx
1742        .lock()
1743        .map_err(|e| ExtismError::msg(format!("host_secret_delete: lock: {e}")))?;
1744    // Permission errors return an error string rather than trapping.
1745    if let Err(e) = ctx.check_perm(PermissionType::PluginStorage, &parsed.key) {
1746        let msg = format!("host_secret_delete: {e}");
1747        plugin.memory_set_val(&mut outputs[0], msg.as_str())?;
1748        return Ok(());
1749    }
1750    let secret_key = ctx.secret_key(&parsed.key);
1751    ctx.secret_store.delete(&secret_key);
1752
1753    plugin.memory_set_val(&mut outputs[0], "")?;
1754    Ok(())
1755}
1756
1757/// Host function: `host_run_wasi_module(input: WasiRunRequest) -> WasiRunResult`
1758///
1759/// Runs a WASI module stored in plugin storage. The guest provides a storage
1760/// key, CLI arguments, optional stdin, virtual filesystem files, and a list
1761/// of output files to capture. Only available when the `wasi-runner` feature
1762/// is enabled.
1763#[cfg(feature = "wasi-runner")]
1764fn host_run_wasi_module(
1765    plugin: &mut CurrentPlugin,
1766    inputs: &[Val],
1767    outputs: &mut [Val],
1768    user_data: UserData<HostContext>,
1769) -> Result<(), ExtismError> {
1770    use base64::Engine;
1771
1772    /// Build a `WasiRunResult`-shaped error envelope so guests can recover
1773    /// gracefully via the SDK's `wasi::run` parser instead of trapping.
1774    fn err_envelope(msg: &str) -> String {
1775        serde_json::json!({
1776            "exit_code": -1,
1777            "stdout": "",
1778            "stderr": msg,
1779            "files": serde_json::Value::Null,
1780            "error": msg,
1781        })
1782        .to_string()
1783    }
1784
1785    let input: String = plugin.memory_get_val(&inputs[0])?;
1786    let request: crate::wasi_runner::WasiRunRequest = match serde_json::from_str(&input) {
1787        Ok(req) => req,
1788        Err(e) => {
1789            let msg = format!("host_run_wasi_module: invalid input: {e}");
1790            plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
1791            return Ok(());
1792        }
1793    };
1794
1795    // Load the WASM module bytes from plugin storage
1796    let ctx = user_data.get()?;
1797    let ctx = ctx
1798        .lock()
1799        .map_err(|e| ExtismError::msg(format!("host_run_wasi_module: lock: {e}")))?;
1800    if let Err(e) = ctx.check_perm(PermissionType::PluginStorage, &request.module_key) {
1801        let msg = format!("host_run_wasi_module: {e}");
1802        plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
1803        return Ok(());
1804    }
1805    let storage_key = ctx.storage_key(&request.module_key);
1806    let wasm_bytes = match ctx.storage.get(&storage_key) {
1807        Some(bytes) => bytes,
1808        None => {
1809            let msg = format!(
1810                "host_run_wasi_module: module not found in storage: {}",
1811                request.module_key
1812            );
1813            plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
1814            return Ok(());
1815        }
1816    };
1817    drop(ctx);
1818
1819    // Decode input files from base64
1820    let decoded_files = if let Some(ref files) = request.files {
1821        let mut map = std::collections::HashMap::new();
1822        for (path, b64) in files {
1823            match base64::engine::general_purpose::STANDARD.decode(b64) {
1824                Ok(data) => {
1825                    map.insert(path.clone(), data);
1826                }
1827                Err(e) => {
1828                    let msg = format!("host_run_wasi_module: base64 decode for {path}: {e}");
1829                    plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
1830                    return Ok(());
1831                }
1832            }
1833        }
1834        Some(map)
1835    } else {
1836        None
1837    };
1838
1839    // Decode stdin from base64
1840    let stdin_bytes = if let Some(ref b64) = request.stdin {
1841        match base64::engine::general_purpose::STANDARD.decode(b64) {
1842            Ok(bytes) => Some(bytes),
1843            Err(e) => {
1844                let msg = format!("host_run_wasi_module: stdin base64 decode: {e}");
1845                plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
1846                return Ok(());
1847            }
1848        }
1849    } else {
1850        None
1851    };
1852
1853    // Run the module
1854    let result = match crate::wasi_runner::run_wasi_module(
1855        &wasm_bytes,
1856        &request.args,
1857        stdin_bytes.as_deref(),
1858        decoded_files.as_ref(),
1859        request.output_files.as_deref(),
1860    ) {
1861        Ok(result) => result,
1862        Err(e) => {
1863            let msg = format!("host_run_wasi_module: {e}");
1864            plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
1865            return Ok(());
1866        }
1867    };
1868
1869    let json = serde_json::to_string(&result)
1870        .map_err(|e| ExtismError::msg(format!("host_run_wasi_module: serialize: {e}")))?;
1871
1872    plugin.memory_set_val(&mut outputs[0], json.as_str())?;
1873    Ok(())
1874}
1875
1876/// Stub for `host_run_wasi_module` when the `wasi-runner` feature is not enabled.
1877#[cfg(not(feature = "wasi-runner"))]
1878fn host_run_wasi_module(
1879    plugin: &mut CurrentPlugin,
1880    _inputs: &[Val],
1881    outputs: &mut [Val],
1882    _user_data: UserData<HostContext>,
1883) -> Result<(), ExtismError> {
1884    let error = serde_json::json!({
1885        "exit_code": -1,
1886        "stdout": "",
1887        "stderr": "host_run_wasi_module: wasi-runner feature not enabled"
1888    });
1889    plugin.memory_set_val(&mut outputs[0], error.to_string().as_str())?;
1890    Ok(())
1891}
1892
1893/// Host function: `host_get_timestamp(input: "") -> timestamp_ms string`
1894///
1895/// Returns the current timestamp in milliseconds since epoch.
1896fn host_get_timestamp(
1897    plugin: &mut CurrentPlugin,
1898    _inputs: &[Val],
1899    outputs: &mut [Val],
1900    _user_data: UserData<HostContext>,
1901) -> Result<(), ExtismError> {
1902    let now = std::time::SystemTime::now()
1903        .duration_since(std::time::UNIX_EPOCH)
1904        .map(|d| d.as_millis() as u64)
1905        .unwrap_or(0);
1906
1907    plugin.memory_set_val(&mut outputs[0], now.to_string().as_str())?;
1908    Ok(())
1909}
1910
1911/// Host function: `host_get_now(input: "") -> local RFC 3339 timestamp string`
1912fn host_get_now(
1913    plugin: &mut CurrentPlugin,
1914    _inputs: &[Val],
1915    outputs: &mut [Val],
1916    _user_data: UserData<HostContext>,
1917) -> Result<(), ExtismError> {
1918    let now = Local::now().to_rfc3339_opts(SecondsFormat::Secs, false);
1919    plugin.memory_set_val(&mut outputs[0], now.as_str())?;
1920    Ok(())
1921}
1922
1923/// Host function: `host_request_file(input: {key}) -> raw bytes or empty`
1924///
1925/// Requests a user-provided file by key name. The host decides where the
1926/// file comes from (CLI: read from path in command args; browser: File picker).
1927/// Returns the raw file bytes, or an empty result if unavailable.
1928fn host_request_file(
1929    plugin: &mut CurrentPlugin,
1930    inputs: &[Val],
1931    outputs: &mut [Val],
1932    user_data: UserData<HostContext>,
1933) -> Result<(), ExtismError> {
1934    let input: String = plugin.memory_get_val(&inputs[0])?;
1935
1936    #[derive(serde::Deserialize)]
1937    struct RequestFileInput {
1938        key: String,
1939    }
1940
1941    let parsed: RequestFileInput = serde_json::from_str(&input)
1942        .map_err(|e| ExtismError::msg(format!("host_request_file: invalid input: {e}")))?;
1943
1944    let ctx = user_data.get()?;
1945    let ctx = ctx
1946        .lock()
1947        .map_err(|e| ExtismError::msg(format!("host_request_file: lock: {e}")))?;
1948
1949    let result = ctx
1950        .file_provider
1951        .get_file(&ctx.plugin_id, &parsed.key)
1952        .unwrap_or_default();
1953
1954    plugin.memory_set_val(&mut outputs[0], result.as_slice())?;
1955    Ok(())
1956}
1957
1958/// Host function: `host_plugin_command(input: {plugin_id, command, params}) -> {success, data?, error?}`
1959///
1960/// Executes a command on another loaded plugin through the host bridge.
1961fn host_plugin_command(
1962    plugin: &mut CurrentPlugin,
1963    inputs: &[Val],
1964    outputs: &mut [Val],
1965    user_data: UserData<HostContext>,
1966) -> Result<(), ExtismError> {
1967    #[derive(serde::Deserialize)]
1968    struct PluginCommandInput {
1969        plugin_id: String,
1970        command: String,
1971        #[serde(default)]
1972        params: serde_json::Value,
1973    }
1974
1975    let input: String = plugin.memory_get_val(&inputs[0])?;
1976    let parsed: PluginCommandInput = serde_json::from_str(&input)
1977        .map_err(|e| ExtismError::msg(format!("host_plugin_command: invalid input: {e}")))?;
1978
1979    let ctx = user_data.get()?;
1980    let ctx = ctx
1981        .lock()
1982        .map_err(|e| ExtismError::msg(format!("host_plugin_command: lock: {e}")))?;
1983
1984    const MAX_PLUGIN_COMMAND_DEPTH: u32 = 8;
1985
1986    let response = if ctx.plugin_command_depth >= MAX_PLUGIN_COMMAND_DEPTH {
1987        serde_json::json!({
1988            "success": false,
1989            "error": format!(
1990                "Cross-plugin command call depth limit exceeded (max {MAX_PLUGIN_COMMAND_DEPTH})"
1991            ),
1992        })
1993    } else if parsed.plugin_id.trim().is_empty() || parsed.command.trim().is_empty() {
1994        serde_json::json!({
1995            "success": false,
1996            "error": "plugin_id and command are required",
1997        })
1998    } else if parsed.plugin_id == ctx.plugin_id {
1999        serde_json::json!({
2000            "success": false,
2001            "error": "Plugins cannot call their own commands via host_plugin_command",
2002        })
2003    } else {
2004        let permission_target = format!("{}:{}", parsed.plugin_id, parsed.command);
2005        match ctx.check_perm(PermissionType::ExecuteCommands, &permission_target) {
2006            Ok(()) => match ctx.plugin_command_bridge.call(
2007                &ctx.plugin_id,
2008                &parsed.plugin_id,
2009                &parsed.command,
2010                parsed.params,
2011            ) {
2012                Ok(data) => serde_json::json!({
2013                    "success": true,
2014                    "data": data,
2015                }),
2016                Err(error) => serde_json::json!({
2017                    "success": false,
2018                    "error": error,
2019                }),
2020            },
2021            Err(error) => serde_json::json!({
2022                "success": false,
2023                "error": error.to_string(),
2024            }),
2025        }
2026    };
2027
2028    let json = serde_json::to_string(&response)
2029        .map_err(|e| ExtismError::msg(format!("host_plugin_command: serialize: {e}")))?;
2030    plugin.memory_set_val(&mut outputs[0], json.as_str())?;
2031    Ok(())
2032}
2033
2034/// Host function: `host_get_runtime_context(input: "") -> json`
2035///
2036/// Returns generic host runtime context for the caller plugin.
2037fn host_get_runtime_context(
2038    plugin: &mut CurrentPlugin,
2039    _inputs: &[Val],
2040    outputs: &mut [Val],
2041    user_data: UserData<HostContext>,
2042) -> Result<(), ExtismError> {
2043    let ctx = user_data.get()?;
2044    let ctx = ctx
2045        .lock()
2046        .map_err(|e| ExtismError::msg(format!("host_get_runtime_context: lock: {e}")))?;
2047    let json = serde_json::to_string(&ctx.runtime_context_provider.get_context(&ctx.plugin_id))
2048        .map_err(|e| ExtismError::msg(format!("host_get_runtime_context: serialize: {e}")))?;
2049    plugin.memory_set_val(&mut outputs[0], json.as_str())?;
2050    Ok(())
2051}
2052
2053/// Host function: `host_ws_request(input: json) -> string`
2054///
2055/// Forward-compatible bridge for plugin-managed websocket ownership.
2056/// The concrete host bridge owns the socket lifecycle and maps these
2057/// requests to runtime-specific websocket operations.
2058fn host_ws_request(
2059    plugin: &mut CurrentPlugin,
2060    inputs: &[Val],
2061    outputs: &mut [Val],
2062    user_data: UserData<HostContext>,
2063) -> Result<(), ExtismError> {
2064    let input: String = plugin.memory_get_val(&inputs[0])?;
2065    let ctx = user_data.get()?;
2066    let ctx = ctx
2067        .lock()
2068        .map_err(|e| ExtismError::msg(format!("host_ws_request: lock: {e}")))?;
2069    // Bridge errors return as a JSON envelope so the guest can recover
2070    // gracefully instead of trapping the entire `handle_command` call.
2071    let result = match ctx.ws_bridge.request(&input) {
2072        Ok(s) => s,
2073        Err(e) => serde_json::json!({
2074            "ok": false,
2075            "error": format!("host_ws_request: {e}"),
2076        })
2077        .to_string(),
2078    };
2079    plugin.memory_set_val(&mut outputs[0], result.as_str())?;
2080    Ok(())
2081}
2082
2083/// Host function: `host_hash_file(input: {path}) -> {hash: "hex..."}`
2084///
2085/// Computes the SHA-256 hash of a workspace file and returns the hex digest.
2086/// Returns an empty string if the file does not exist.
2087fn host_hash_file(
2088    plugin: &mut CurrentPlugin,
2089    inputs: &[Val],
2090    outputs: &mut [Val],
2091    user_data: UserData<HostContext>,
2092) -> Result<(), ExtismError> {
2093    let input: String = plugin.memory_get_val(&inputs[0])?;
2094
2095    #[derive(serde::Deserialize)]
2096    struct HashInput {
2097        path: String,
2098    }
2099
2100    let parsed: HashInput = serde_json::from_str(&input)
2101        .map_err(|e| ExtismError::msg(format!("host_hash_file: invalid input: {e}")))?;
2102    let path = HostContext::validate_file_path(&parsed.path)?;
2103
2104    let ctx = user_data.get()?;
2105    let ctx = ctx
2106        .lock()
2107        .map_err(|e| ExtismError::msg(format!("host_hash_file: lock: {e}")))?;
2108    // Permission errors return empty (same as file-not-found) rather than trapping.
2109    if ctx.check_perm(PermissionType::ReadFiles, &path).is_err() {
2110        plugin.memory_set_val(&mut outputs[0], "")?;
2111        return Ok(());
2112    }
2113
2114    let hash = match futures_lite::future::block_on(ctx.fs.hash_file(Path::new(&path))) {
2115        Ok(hash) => hash,
2116        Err(_) => {
2117            plugin.memory_set_val(&mut outputs[0], "")?;
2118            return Ok(());
2119        }
2120    };
2121
2122    let json = serde_json::json!({ "hash": hash }).to_string();
2123    plugin.memory_set_val(&mut outputs[0], json.as_str())?;
2124    Ok(())
2125}
2126
2127/// Host function: `host_proxy_request(input: {proxy_id, path, method, headers, body?}) -> {status, headers, body}`
2128///
2129/// Routes a request through the server's generic proxy service.
2130/// The host resolves the server URL and auth token from the runtime context,
2131/// then makes a request to `POST {server}/api/proxy/{proxy_id}/{path}`.
2132#[cfg(feature = "http")]
2133fn host_proxy_request(
2134    plugin: &mut CurrentPlugin,
2135    inputs: &[Val],
2136    outputs: &mut [Val],
2137    user_data: UserData<HostContext>,
2138) -> Result<(), ExtismError> {
2139    let input: String = plugin.memory_get_val(&inputs[0])?;
2140
2141    #[derive(serde::Deserialize)]
2142    struct ProxyInput {
2143        proxy_id: String,
2144        #[serde(default)]
2145        path: String,
2146        #[serde(default = "default_method")]
2147        method: String,
2148        #[serde(default)]
2149        headers: std::collections::HashMap<String, String>,
2150        body: Option<String>,
2151    }
2152
2153    fn default_method() -> String {
2154        "POST".to_string()
2155    }
2156
2157    #[derive(serde::Serialize)]
2158    struct ProxyOutput {
2159        status: u16,
2160        headers: std::collections::HashMap<String, String>,
2161        body: String,
2162        #[serde(skip_serializing_if = "Option::is_none")]
2163        body_base64: Option<String>,
2164    }
2165
2166    /// Build an `HttpResponse`-shaped error envelope so guests can recover via
2167    /// the SDK's `proxy::request` parser instead of trapping.
2168    fn err_envelope(msg: &str) -> String {
2169        serde_json::json!({
2170            "status": 0,
2171            "headers": {},
2172            "body": msg,
2173            "error": msg,
2174        })
2175        .to_string()
2176    }
2177
2178    let parsed: ProxyInput = match serde_json::from_str(&input) {
2179        Ok(p) => p,
2180        Err(e) => {
2181            let msg = format!("host_proxy_request: invalid input: {e}");
2182            plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2183            return Ok(());
2184        }
2185    };
2186
2187    if let Err(e) = HostContext::validate_http_headers(&parsed.headers) {
2188        let msg = format!("host_proxy_request: {e}");
2189        plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2190        return Ok(());
2191    }
2192
2193    // Resolve server URL and auth token from runtime context
2194    let (server_url, auth_token) = {
2195        let ctx = user_data.get()?;
2196        let ctx = ctx
2197            .lock()
2198            .map_err(|e| ExtismError::msg(format!("host_proxy_request: lock: {e}")))?;
2199
2200        let runtime_json = ctx.runtime_context_provider.get_context(&ctx.plugin_id);
2201        let server_url = match runtime_json
2202            .get("server_url")
2203            .and_then(|v| v.as_str())
2204            .map(|s| s.trim_end_matches('/').to_string())
2205        {
2206            Some(url) => url,
2207            None => {
2208                let msg = "host_proxy_request: server_url not available in runtime context";
2209                plugin.memory_set_val(&mut outputs[0], err_envelope(msg).as_str())?;
2210                return Ok(());
2211            }
2212        };
2213        let auth_token = runtime_json
2214            .get("auth_token")
2215            .and_then(|v| v.as_str())
2216            .map(|s| s.to_string());
2217        (server_url, auth_token)
2218    };
2219
2220    // Build proxy URL
2221    let proxy_url = if parsed.path.is_empty() {
2222        format!("{}/api/proxy/{}", server_url, parsed.proxy_id)
2223    } else {
2224        format!(
2225            "{}/api/proxy/{}/{}",
2226            server_url,
2227            parsed.proxy_id,
2228            parsed.path.trim_start_matches('/')
2229        )
2230    };
2231
2232    // Build request
2233    let agent: ureq::Agent = ureq::Agent::config_builder()
2234        .timeout_global(Some(std::time::Duration::from_secs(120)))
2235        .http_status_as_error(false)
2236        .build()
2237        .into();
2238
2239    let mut request_builder = ureq::http::Request::builder()
2240        .method(parsed.method.as_str())
2241        .uri(proxy_url.as_str())
2242        .header("Content-Type", "application/json");
2243
2244    if let Some(ref token) = auth_token {
2245        request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
2246    }
2247
2248    for (key, value) in &parsed.headers {
2249        request_builder = request_builder.header(key, value);
2250    }
2251
2252    let response = if let Some(body) = &parsed.body {
2253        match request_builder.body(body.clone()) {
2254            Ok(request) => match agent.run(request) {
2255                Ok(r) => r,
2256                Err(e) => {
2257                    let msg = format!("host_proxy_request: {e}");
2258                    plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2259                    return Ok(());
2260                }
2261            },
2262            Err(e) => {
2263                let msg = format!("host_proxy_request: build request: {e}");
2264                plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2265                return Ok(());
2266            }
2267        }
2268    } else {
2269        match request_builder.body(()) {
2270            Ok(request) => match agent.run(request) {
2271                Ok(r) => r,
2272                Err(e) => {
2273                    let msg = format!("host_proxy_request: {e}");
2274                    plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2275                    return Ok(());
2276                }
2277            },
2278            Err(e) => {
2279                let msg = format!("host_proxy_request: build request: {e}");
2280                plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2281                return Ok(());
2282            }
2283        }
2284    };
2285
2286    let status = response.status().as_u16();
2287    let mut resp_headers = std::collections::HashMap::new();
2288    for (name, value) in response.headers() {
2289        if let Ok(v) = value.to_str() {
2290            resp_headers.insert(name.to_string(), v.to_string());
2291        }
2292    }
2293    let mut response = response;
2294    let body_bytes = match response
2295        .body_mut()
2296        .with_config()
2297        .limit(128 * 1024 * 1024)
2298        .read_to_vec()
2299    {
2300        Ok(bytes) => bytes,
2301        Err(e) => {
2302            let msg = format!("host_proxy_request: read body: {e}");
2303            plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2304            return Ok(());
2305        }
2306    };
2307    let body = String::from_utf8_lossy(&body_bytes).to_string();
2308    use base64::Engine as _;
2309    let body_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes));
2310
2311    let output = ProxyOutput {
2312        status,
2313        headers: resp_headers,
2314        body,
2315        body_base64,
2316    };
2317
2318    let json = serde_json::to_string(&output)
2319        .map_err(|e| ExtismError::msg(format!("host_proxy_request: serialize: {e}")))?;
2320    plugin.memory_set_val(&mut outputs[0], json.as_str())?;
2321    Ok(())
2322}
2323
2324/// Host function: `host_http_request(input: {url, method, headers, body?, timeout_ms?}) -> {status, headers, body}`
2325///
2326/// Performs an HTTP request and returns the response. Only available when
2327/// the `http` feature is enabled (native builds). On WASM the browser
2328/// host functions provide the equivalent via `fetch()`.
2329#[cfg(feature = "http")]
2330fn host_http_request(
2331    plugin: &mut CurrentPlugin,
2332    inputs: &[Val],
2333    outputs: &mut [Val],
2334    user_data: UserData<HostContext>,
2335) -> Result<(), ExtismError> {
2336    use base64::Engine as _;
2337    use ureq::http::Request;
2338
2339    let input: String = plugin.memory_get_val(&inputs[0])?;
2340
2341    #[derive(serde::Deserialize)]
2342    struct HttpInput {
2343        url: String,
2344        method: String,
2345        headers: std::collections::HashMap<String, String>,
2346        body: Option<String>,
2347        /// Base64-encoded binary body. Takes priority over `body` when present.
2348        body_base64: Option<String>,
2349        /// Optional request timeout in milliseconds.
2350        timeout_ms: Option<u64>,
2351    }
2352
2353    #[derive(serde::Serialize)]
2354    struct HttpOutput {
2355        status: u16,
2356        headers: std::collections::HashMap<String, String>,
2357        body: String,
2358        #[serde(skip_serializing_if = "Option::is_none")]
2359        body_base64: Option<String>,
2360    }
2361
2362    /// Build an `HttpResponse`-shaped error envelope so guests can recover via
2363    /// the SDK's `http::request` parser instead of trapping.
2364    fn err_envelope(msg: &str) -> String {
2365        serde_json::json!({
2366            "status": 0,
2367            "headers": {},
2368            "body": msg,
2369            "error": msg,
2370        })
2371        .to_string()
2372    }
2373
2374    let parsed: HttpInput = match serde_json::from_str(&input) {
2375        Ok(p) => p,
2376        Err(e) => {
2377            let msg = format!("host_http_request: invalid input: {e}");
2378            plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2379            return Ok(());
2380        }
2381    };
2382
2383    if let Err(e) = HostContext::validate_http_headers(&parsed.headers) {
2384        let msg = format!("host_http_request: {e}");
2385        plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2386        return Ok(());
2387    }
2388
2389    {
2390        let ctx = user_data.get()?;
2391        let ctx = ctx
2392            .lock()
2393            .map_err(|e| ExtismError::msg(format!("host_http_request: lock: {e}")))?;
2394        if let Err(e) = ctx.check_perm(PermissionType::HttpRequests, &parsed.url) {
2395            let msg = format!("host_http_request: {e}");
2396            plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2397            return Ok(());
2398        }
2399    }
2400
2401    const MIN_HTTP_TIMEOUT_MS: u64 = 1_000;
2402    const MAX_HTTP_TIMEOUT_MS: u64 = 300_000;
2403
2404    let timeout = parsed
2405        .timeout_ms
2406        .map(|value| value.clamp(MIN_HTTP_TIMEOUT_MS, MAX_HTTP_TIMEOUT_MS))
2407        .map(std::time::Duration::from_millis);
2408    let agent: ureq::Agent = ureq::Agent::config_builder()
2409        .timeout_global(timeout)
2410        .http_status_as_error(false)
2411        .build()
2412        .into();
2413
2414    let mut request_builder = Request::builder()
2415        .method(parsed.method.as_str())
2416        .uri(parsed.url.as_str());
2417    for (key, value) in &parsed.headers {
2418        request_builder = request_builder.header(key, value);
2419    }
2420
2421    let response = if let Some(b64) = &parsed.body_base64 {
2422        let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) {
2423            Ok(bytes) => bytes,
2424            Err(e) => {
2425                let msg = format!("host_http_request: base64 decode: {e}");
2426                plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2427                return Ok(());
2428            }
2429        };
2430        match request_builder.body(bytes) {
2431            Ok(request) => match agent.run(request) {
2432                Ok(r) => r,
2433                Err(e) => {
2434                    let msg = format!("host_http_request: {e}");
2435                    plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2436                    return Ok(());
2437                }
2438            },
2439            Err(e) => {
2440                let msg = format!("host_http_request: invalid request: {e}");
2441                plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2442                return Ok(());
2443            }
2444        }
2445    } else if let Some(body) = &parsed.body {
2446        match request_builder.body(body.clone()) {
2447            Ok(request) => match agent.run(request) {
2448                Ok(r) => r,
2449                Err(e) => {
2450                    let msg = format!("host_http_request: {e}");
2451                    plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2452                    return Ok(());
2453                }
2454            },
2455            Err(e) => {
2456                let msg = format!("host_http_request: invalid request: {e}");
2457                plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2458                return Ok(());
2459            }
2460        }
2461    } else {
2462        match request_builder.body(()) {
2463            Ok(request) => match agent.run(request) {
2464                Ok(r) => r,
2465                Err(e) => {
2466                    let msg = format!("host_http_request: {e}");
2467                    plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2468                    return Ok(());
2469                }
2470            },
2471            Err(e) => {
2472                let msg = format!("host_http_request: invalid request: {e}");
2473                plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2474                return Ok(());
2475            }
2476        }
2477    };
2478
2479    let status = response.status().as_u16();
2480    if status >= 400 {
2481        log::warn!(
2482            "host_http_request: {} {} → {} (plugin={})",
2483            parsed.method,
2484            parsed.url,
2485            status,
2486            {
2487                let ctx = user_data.get().ok();
2488                ctx.and_then(|c| c.lock().ok().map(|g| g.plugin_id.clone()))
2489                    .unwrap_or_default()
2490            },
2491        );
2492    }
2493    let mut resp_headers = std::collections::HashMap::new();
2494    for (name, value) in response.headers() {
2495        if let Ok(value) = value.to_str() {
2496            resp_headers.insert(name.to_string(), value.to_string());
2497        }
2498    }
2499    let mut response = response;
2500    // Raise the default 10 MB body limit so plugins can download large WASM
2501    // binaries (e.g. pandoc.wasm ~58 MB).
2502    let body_bytes = match response
2503        .body_mut()
2504        .with_config()
2505        .limit(128 * 1024 * 1024)
2506        .read_to_vec()
2507    {
2508        Ok(bytes) => bytes,
2509        Err(e) => {
2510            let msg = format!("host_http_request: read body: {e}");
2511            plugin.memory_set_val(&mut outputs[0], err_envelope(&msg).as_str())?;
2512            return Ok(());
2513        }
2514    };
2515    let body = String::from_utf8_lossy(&body_bytes).to_string();
2516    let body_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes));
2517
2518    let output = HttpOutput {
2519        status,
2520        headers: resp_headers,
2521        body,
2522        body_base64,
2523    };
2524
2525    let json = serde_json::to_string(&output)
2526        .map_err(|e| ExtismError::msg(format!("host_http_request: serialize: {e}")))?;
2527
2528    plugin.memory_set_val(&mut outputs[0], json.as_str())?;
2529    Ok(())
2530}
2531
2532/// Stub for `host_http_request` when the `http` feature is not enabled.
2533#[cfg(not(feature = "http"))]
2534fn host_http_request(
2535    plugin: &mut CurrentPlugin,
2536    _inputs: &[Val],
2537    outputs: &mut [Val],
2538    _user_data: UserData<HostContext>,
2539) -> Result<(), ExtismError> {
2540    let error = serde_json::json!({
2541        "status": 0,
2542        "headers": {},
2543        "body": "host_http_request: http feature not enabled"
2544    });
2545    plugin.memory_set_val(&mut outputs[0], error.to_string().as_str())?;
2546    Ok(())
2547}
2548
2549#[cfg(not(feature = "http"))]
2550fn host_proxy_request(
2551    plugin: &mut CurrentPlugin,
2552    _inputs: &[Val],
2553    outputs: &mut [Val],
2554    _user_data: UserData<HostContext>,
2555) -> Result<(), ExtismError> {
2556    let error = serde_json::json!({
2557        "status": 0,
2558        "headers": {},
2559        "body": "host_proxy_request: http feature not enabled"
2560    });
2561    plugin.memory_set_val(&mut outputs[0], error.to_string().as_str())?;
2562    Ok(())
2563}
2564
2565/// Host function: `host_namespace_put_object(input: {ns_id, key, body_base64, mime_type, audience?}) -> {ok: true} or {error}`
2566fn host_namespace_put_object(
2567    plugin: &mut CurrentPlugin,
2568    inputs: &[Val],
2569    outputs: &mut [Val],
2570    user_data: UserData<HostContext>,
2571) -> Result<(), ExtismError> {
2572    use base64::Engine as _;
2573
2574    let input: String = plugin.memory_get_val(&inputs[0])?;
2575
2576    #[derive(serde::Deserialize)]
2577    struct Input {
2578        ns_id: String,
2579        key: String,
2580        body_base64: String,
2581        mime_type: String,
2582        #[serde(default)]
2583        audience: Option<String>,
2584    }
2585
2586    let parsed: Input = serde_json::from_str(&input)
2587        .map_err(|e| ExtismError::msg(format!("host_namespace_put_object: invalid input: {e}")))?;
2588
2589    let bytes = base64::engine::general_purpose::STANDARD
2590        .decode(&parsed.body_base64)
2591        .map_err(|e| ExtismError::msg(format!("host_namespace_put_object: base64 decode: {e}")))?;
2592
2593    let ctx = user_data.get()?;
2594    let ctx = ctx
2595        .lock()
2596        .map_err(|e| ExtismError::msg(format!("host_namespace_put_object: lock: {e}")))?;
2597    let result = ctx.namespace_provider.put_object(
2598        &parsed.ns_id,
2599        &parsed.key,
2600        &bytes,
2601        &parsed.mime_type,
2602        parsed.audience.as_deref(),
2603    );
2604
2605    let json = match result {
2606        Ok(()) => serde_json::json!({ "ok": true }),
2607        Err(e) => serde_json::json!({ "error": e }),
2608    };
2609    plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
2610    Ok(())
2611}
2612
2613/// Host function: `host_namespace_get_object(input: {ns_id, key}) -> {data: "<base64>"} or {error}`
2614fn host_namespace_get_object(
2615    plugin: &mut CurrentPlugin,
2616    inputs: &[Val],
2617    outputs: &mut [Val],
2618    user_data: UserData<HostContext>,
2619) -> Result<(), ExtismError> {
2620    use base64::Engine as _;
2621
2622    let input: String = plugin.memory_get_val(&inputs[0])?;
2623
2624    #[derive(serde::Deserialize)]
2625    struct Input {
2626        ns_id: String,
2627        key: String,
2628    }
2629
2630    let parsed: Input = serde_json::from_str(&input)
2631        .map_err(|e| ExtismError::msg(format!("host_namespace_get_object: invalid input: {e}")))?;
2632
2633    let ctx = user_data.get()?;
2634    let ctx = ctx
2635        .lock()
2636        .map_err(|e| ExtismError::msg(format!("host_namespace_get_object: lock: {e}")))?;
2637    let result = ctx
2638        .namespace_provider
2639        .get_object(&parsed.ns_id, &parsed.key);
2640
2641    let json = match result {
2642        Ok(bytes) => {
2643            let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
2644            serde_json::json!({ "data": encoded })
2645        }
2646        Err(e) => serde_json::json!({ "error": e }),
2647    };
2648    plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
2649    Ok(())
2650}
2651
2652/// Host function: `host_namespace_get_objects_batch(input: {ns_id, keys}) -> {objects: {key: {data, mime_type}}, errors: {key: msg}}`
2653fn host_namespace_get_objects_batch(
2654    plugin: &mut CurrentPlugin,
2655    inputs: &[Val],
2656    outputs: &mut [Val],
2657    user_data: UserData<HostContext>,
2658) -> Result<(), ExtismError> {
2659    use base64::Engine as _;
2660
2661    let input: String = plugin.memory_get_val(&inputs[0])?;
2662
2663    #[derive(serde::Deserialize)]
2664    struct Input {
2665        ns_id: String,
2666        keys: Vec<String>,
2667    }
2668
2669    let parsed: Input = serde_json::from_str(&input).map_err(|e| {
2670        ExtismError::msg(format!(
2671            "host_namespace_get_objects_batch: invalid input: {e}"
2672        ))
2673    })?;
2674
2675    let ctx = user_data.get()?;
2676    let ctx = ctx
2677        .lock()
2678        .map_err(|e| ExtismError::msg(format!("host_namespace_get_objects_batch: lock: {e}")))?;
2679
2680    let result = ctx
2681        .namespace_provider
2682        .get_objects_batch(&parsed.ns_id, &parsed.keys);
2683
2684    let json = match result {
2685        Ok(batch) => {
2686            let objects: serde_json::Map<String, serde_json::Value> = batch
2687                .objects
2688                .into_iter()
2689                .map(|(key, entry)| {
2690                    let is_text = entry.mime_type.starts_with("text/");
2691                    let val = if is_text {
2692                        serde_json::json!({
2693                            "data": String::from_utf8_lossy(&entry.bytes),
2694                            "mime_type": entry.mime_type,
2695                            "encoding": "text",
2696                        })
2697                    } else {
2698                        serde_json::json!({
2699                            "data": base64::engine::general_purpose::STANDARD.encode(&entry.bytes),
2700                            "mime_type": entry.mime_type,
2701                            "encoding": "base64",
2702                        })
2703                    };
2704                    (key, val)
2705                })
2706                .collect();
2707
2708            let mut resp = serde_json::json!({ "objects": objects });
2709            if !batch.errors.is_empty() {
2710                resp["errors"] = serde_json::json!(batch.errors);
2711            }
2712            resp
2713        }
2714        Err(e) => serde_json::json!({ "error": e }),
2715    };
2716    plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
2717    Ok(())
2718}
2719
2720/// Host function: `host_namespace_delete_object(input: {ns_id, key}) -> {ok: true} or {error}`
2721fn host_namespace_delete_object(
2722    plugin: &mut CurrentPlugin,
2723    inputs: &[Val],
2724    outputs: &mut [Val],
2725    user_data: UserData<HostContext>,
2726) -> Result<(), ExtismError> {
2727    let input: String = plugin.memory_get_val(&inputs[0])?;
2728
2729    #[derive(serde::Deserialize)]
2730    struct Input {
2731        ns_id: String,
2732        key: String,
2733    }
2734
2735    let parsed: Input = serde_json::from_str(&input).map_err(|e| {
2736        ExtismError::msg(format!("host_namespace_delete_object: invalid input: {e}"))
2737    })?;
2738
2739    let ctx = user_data.get()?;
2740    let ctx = ctx
2741        .lock()
2742        .map_err(|e| ExtismError::msg(format!("host_namespace_delete_object: lock: {e}")))?;
2743    let result = ctx
2744        .namespace_provider
2745        .delete_object(&parsed.ns_id, &parsed.key);
2746
2747    let json = match result {
2748        Ok(()) => serde_json::json!({ "ok": true }),
2749        Err(e) => serde_json::json!({ "error": e }),
2750    };
2751    plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
2752    Ok(())
2753}
2754
2755/// Host function: `host_namespace_list_objects(input: {ns_id, prefix?, limit?, offset?}) -> [{key, audience?, mime_type?, size_bytes?, updated_at?, content_hash?}]`
2756fn host_namespace_list_objects(
2757    plugin: &mut CurrentPlugin,
2758    inputs: &[Val],
2759    outputs: &mut [Val],
2760    user_data: UserData<HostContext>,
2761) -> Result<(), ExtismError> {
2762    let input: String = plugin.memory_get_val(&inputs[0])?;
2763
2764    #[derive(serde::Deserialize)]
2765    struct Input {
2766        ns_id: String,
2767        #[serde(default)]
2768        prefix: Option<String>,
2769        #[serde(default)]
2770        limit: Option<u32>,
2771        #[serde(default)]
2772        offset: Option<u32>,
2773    }
2774
2775    let parsed: Input = serde_json::from_str(&input).map_err(|e| {
2776        ExtismError::msg(format!("host_namespace_list_objects: invalid input: {e}"))
2777    })?;
2778
2779    let ctx = user_data.get()?;
2780    let ctx = ctx
2781        .lock()
2782        .map_err(|e| ExtismError::msg(format!("host_namespace_list_objects: lock: {e}")))?;
2783    let result = ctx.namespace_provider.list_objects(
2784        &parsed.ns_id,
2785        parsed.prefix.as_deref(),
2786        parsed.limit,
2787        parsed.offset,
2788    );
2789
2790    let json = match result {
2791        Ok(objects) => serde_json::to_value(&objects).unwrap_or(serde_json::json!([])),
2792        Err(e) => serde_json::json!({ "error": e }),
2793    };
2794    plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
2795    Ok(())
2796}
2797
2798/// Host function: `host_namespace_list(input: {}) -> [NamespaceEntry] or {error}`
2799fn host_namespace_list(
2800    plugin: &mut CurrentPlugin,
2801    inputs: &[Val],
2802    outputs: &mut [Val],
2803    user_data: UserData<HostContext>,
2804) -> Result<(), ExtismError> {
2805    let _input: String = plugin.memory_get_val(&inputs[0])?;
2806
2807    let ctx = user_data.get()?;
2808    let ctx = ctx
2809        .lock()
2810        .map_err(|e| ExtismError::msg(format!("host_namespace_list: lock: {e}")))?;
2811    let result = ctx.namespace_provider.list_namespaces();
2812
2813    let json = match result {
2814        Ok(entries) => serde_json::to_value(&entries).unwrap_or(serde_json::json!([])),
2815        Err(e) => serde_json::json!({ "error": e }),
2816    };
2817    plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
2818    Ok(())
2819}
2820
2821/// Host function: `host_namespace_create(input: {metadata?}) -> NamespaceEntry or {error}`
2822fn host_namespace_create(
2823    plugin: &mut CurrentPlugin,
2824    inputs: &[Val],
2825    outputs: &mut [Val],
2826    user_data: UserData<HostContext>,
2827) -> Result<(), ExtismError> {
2828    let input: String = plugin.memory_get_val(&inputs[0])?;
2829
2830    #[derive(serde::Deserialize)]
2831    struct Input {
2832        #[serde(default)]
2833        metadata: Option<serde_json::Value>,
2834    }
2835
2836    let parsed: Input = serde_json::from_str(&input)
2837        .map_err(|e| ExtismError::msg(format!("host_namespace_create: invalid input: {e}")))?;
2838
2839    let ctx = user_data.get()?;
2840    let ctx = ctx
2841        .lock()
2842        .map_err(|e| ExtismError::msg(format!("host_namespace_create: lock: {e}")))?;
2843    let result = ctx
2844        .namespace_provider
2845        .create_namespace(parsed.metadata.as_ref());
2846
2847    let json = match result {
2848        Ok(entry) => serde_json::to_value(&entry).unwrap_or_else(|_| serde_json::json!({})),
2849        Err(e) => serde_json::json!({ "error": e }),
2850    };
2851    plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
2852    Ok(())
2853}
2854
2855/// Host function: `host_namespace_sync_audience(input: {ns_id, audience, access}) -> {ok: true} or {error}`
2856fn host_namespace_sync_audience(
2857    plugin: &mut CurrentPlugin,
2858    inputs: &[Val],
2859    outputs: &mut [Val],
2860    user_data: UserData<HostContext>,
2861) -> Result<(), ExtismError> {
2862    let input: String = plugin.memory_get_val(&inputs[0])?;
2863
2864    #[derive(serde::Deserialize)]
2865    struct Input {
2866        ns_id: String,
2867        audience: String,
2868        access: String,
2869    }
2870
2871    let parsed: Input = serde_json::from_str(&input).map_err(|e| {
2872        ExtismError::msg(format!("host_namespace_sync_audience: invalid input: {e}"))
2873    })?;
2874
2875    let ctx = user_data.get()?;
2876    let ctx = ctx
2877        .lock()
2878        .map_err(|e| ExtismError::msg(format!("host_namespace_sync_audience: lock: {e}")))?;
2879    let result =
2880        ctx.namespace_provider
2881            .sync_audience(&parsed.ns_id, &parsed.audience, &parsed.access);
2882
2883    let json = match result {
2884        Ok(()) => serde_json::json!({ "ok": true }),
2885        Err(e) => serde_json::json!({ "error": e }),
2886    };
2887    plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
2888    Ok(())
2889}
2890
2891/// Host function: `host_namespace_send_email(input: {ns_id, audience, subject, reply_to?}) -> {recipients, send_receipt_key} or {error}`
2892fn host_namespace_send_email(
2893    plugin: &mut CurrentPlugin,
2894    inputs: &[Val],
2895    outputs: &mut [Val],
2896    user_data: UserData<HostContext>,
2897) -> Result<(), ExtismError> {
2898    let input: String = plugin.memory_get_val(&inputs[0])?;
2899
2900    #[derive(serde::Deserialize)]
2901    struct Input {
2902        ns_id: String,
2903        audience: String,
2904        subject: String,
2905        reply_to: Option<String>,
2906    }
2907
2908    let parsed: Input = serde_json::from_str(&input)
2909        .map_err(|e| ExtismError::msg(format!("host_namespace_send_email: invalid input: {e}")))?;
2910
2911    let ctx = user_data.get()?;
2912    let ctx = ctx
2913        .lock()
2914        .map_err(|e| ExtismError::msg(format!("host_namespace_send_email: lock: {e}")))?;
2915    let result = ctx.namespace_provider.send_audience_email(
2916        &parsed.ns_id,
2917        &parsed.audience,
2918        &parsed.subject,
2919        parsed.reply_to.as_deref(),
2920    );
2921
2922    let json = match result {
2923        Ok(val) => val,
2924        Err(e) => serde_json::json!({ "error": e }),
2925    };
2926    plugin.memory_set_val(&mut outputs[0], json.to_string().as_str())?;
2927    Ok(())
2928}