diff --git a/crates/volt-core/src/security/mod.rs b/crates/volt-core/src/security/mod.rs new file mode 100644 index 0000000..1be740d --- /dev/null +++ b/crates/volt-core/src/security/mod.rs @@ -0,0 +1,139 @@ +//! Content Security Policy generation for WebView responses. + +mod validation; + +pub use self::validation::{validate_path, validate_url_scheme}; + +const VOLT_EMBEDDED_ORIGINS: &str = "volt://localhost http://volt.localhost https://volt.localhost"; + +/// Default CSP for production builds - strict, no unsafe-eval. +pub fn production_csp() -> String { + [ + "default-src 'none'", + &format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS}"), + &format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS}"), + &format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS}"), + &format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS}"), + &format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS}"), + ] + .join("; ") +} + +/// CSP for development builds - allows connections to localhost dev servers. +pub fn development_csp(dev_server_origin: &str) -> String { + let Some((safe_http_origin, safe_ws_origin)) = sanitize_dev_server_origin(dev_server_origin) + else { + return [ + "default-src 'none'", + &format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS}"), + &format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS}"), + &format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS}"), + &format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS}"), + &format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS}"), + ] + .join("; "); + }; + + [ + "default-src 'none'", + &format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"), + &format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"), + &format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"), + &format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"), + &format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin} {safe_ws_origin}"), + ] + .join("; ") +} + +fn sanitize_dev_server_origin(dev_server_origin: &str) -> Option<(String, String)> { + let parsed = url::Url::parse(dev_server_origin).ok()?; + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + return None; + } + let host = parsed.host_str()?; + if host.contains(';') + || host.contains('\n') + || host.contains('\r') + || host.chars().any(|ch| ch.is_ascii_whitespace()) + { + return None; + } + let mut http_origin = format!("{scheme}://{host}"); + if let Some(port) = parsed.port() { + http_origin.push(':'); + http_origin.push_str(&port.to_string()); + } + let ws_scheme = if scheme == "https" { "wss" } else { "ws" }; + let ws_origin = http_origin.replacen(scheme, ws_scheme, 1); + Some((http_origin, ws_origin)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_production_csp() { + let csp = production_csp(); + assert!(csp.contains("default-src 'none'")); + assert!(csp.contains("script-src 'self'")); + assert!(csp.contains("volt://localhost")); + assert!(csp.contains("https://volt.localhost")); + assert!(!csp.contains("unsafe-eval")); + assert!(!csp.contains("*")); + } + + #[test] + fn test_development_csp_includes_origin() { + let csp = development_csp("http://localhost:5173"); + assert!(csp.contains("http://localhost:5173")); + assert!(csp.contains("volt://localhost")); + assert!(csp.contains("https://volt.localhost")); + assert!(csp.contains("connect-src")); + assert!(csp.contains("script-src")); + } + + #[test] + fn test_development_csp_includes_websocket() { + let csp = development_csp("http://localhost:5173"); + assert!(csp.contains("ws://localhost:5173")); + assert!( + !csp.contains("ws://http://"), + "should not have double protocol" + ); + } + + #[test] + fn test_development_csp_https_uses_wss() { + let csp = development_csp("https://localhost:5173"); + assert!(csp.contains("wss://localhost:5173")); + assert!( + !csp.contains("ws://https://"), + "should not have double protocol" + ); + } + + #[test] + fn test_development_csp_rejects_invalid_origin_injection() { + let csp = development_csp("http://localhost:5173;script-src *"); + assert!(!csp.contains("localhost:5173;script-src")); + assert!(csp.contains("script-src 'self'")); + assert!(!csp.contains("script-src *")); + } + + #[test] + fn test_development_csp_rejects_whitespace_in_origin() { + let csp = development_csp("http://localhost :5173"); + assert!(!csp.contains("localhost :5173")); + } + + #[test] + fn test_production_csp_has_only_explicit_localhost_allowances() { + let csp = production_csp(); + assert!(csp.contains("https://volt.localhost")); + assert!(!csp.contains("http://localhost")); + assert!(!csp.contains("https://localhost")); + assert!(!csp.contains("ws://")); + } +} diff --git a/crates/volt-core/src/security.rs b/crates/volt-core/src/security/validation.rs similarity index 54% rename from crates/volt-core/src/security.rs rename to crates/volt-core/src/security/validation.rs index 0ccca60..fec6aa5 100644 --- a/crates/volt-core/src/security.rs +++ b/crates/volt-core/src/security/validation.rs @@ -1,72 +1,5 @@ -//! Content Security Policy generation for WebView responses. - use unicode_normalization::UnicodeNormalization; -const VOLT_EMBEDDED_ORIGINS: &str = "volt://localhost http://volt.localhost https://volt.localhost"; - -/// Default CSP for production builds - strict, no unsafe-eval. -pub fn production_csp() -> String { - [ - "default-src 'none'", - &format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS}"), - &format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS}"), - &format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS}"), - &format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS}"), - &format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS}"), - ] - .join("; ") -} - -/// CSP for development builds - allows connections to localhost dev servers. -pub fn development_csp(dev_server_origin: &str) -> String { - let Some((safe_http_origin, safe_ws_origin)) = sanitize_dev_server_origin(dev_server_origin) - else { - return [ - "default-src 'none'", - &format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS}"), - &format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS}"), - &format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS}"), - &format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS}"), - &format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS}"), - ] - .join("; "); - }; - - [ - "default-src 'none'", - &format!("script-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"), - &format!("style-src 'self' 'unsafe-inline' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"), - &format!("img-src 'self' data: {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"), - &format!("font-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin}"), - &format!("connect-src 'self' {VOLT_EMBEDDED_ORIGINS} {safe_http_origin} {safe_ws_origin}"), - ] - .join("; ") -} - -fn sanitize_dev_server_origin(dev_server_origin: &str) -> Option<(String, String)> { - let parsed = url::Url::parse(dev_server_origin).ok()?; - let scheme = parsed.scheme(); - if scheme != "http" && scheme != "https" { - return None; - } - let host = parsed.host_str()?; - if host.contains(';') - || host.contains('\n') - || host.contains('\r') - || host.chars().any(|ch| ch.is_ascii_whitespace()) - { - return None; - } - let mut http_origin = format!("{scheme}://{host}"); - if let Some(port) = parsed.port() { - http_origin.push(':'); - http_origin.push_str(&port.to_string()); - } - let ws_scheme = if scheme == "https" { "wss" } else { "ws" }; - let ws_origin = http_origin.replacen(scheme, ws_scheme, 1); - Some((http_origin, ws_origin)) -} - /// Validate that a path string does not attempt directory traversal. pub fn validate_path(path: &str) -> Result<(), String> { let normalized = path.nfc().collect::(); @@ -129,17 +62,6 @@ pub fn validate_url_scheme(url: &str) -> Result<(), String> { mod tests { use super::*; - #[test] - fn test_production_csp() { - let csp = production_csp(); - assert!(csp.contains("default-src 'none'")); - assert!(csp.contains("script-src 'self'")); - assert!(csp.contains("volt://localhost")); - assert!(csp.contains("https://volt.localhost")); - assert!(!csp.contains("unsafe-eval")); - assert!(!csp.contains("*")); - } - #[test] fn test_path_traversal_blocked() { assert!(validate_path("../../etc/passwd").is_err()); @@ -177,61 +99,6 @@ mod tests { assert!(validate_url_scheme("vbscript:msgbox").is_err()); } - // ── Expanded tests ───────────────────────────────────────────── - - #[test] - fn test_development_csp_includes_origin() { - let csp = development_csp("http://localhost:5173"); - assert!(csp.contains("http://localhost:5173")); - assert!(csp.contains("volt://localhost")); - assert!(csp.contains("https://volt.localhost")); - assert!(csp.contains("connect-src")); - assert!(csp.contains("script-src")); - } - - #[test] - fn test_development_csp_includes_websocket() { - let csp = development_csp("http://localhost:5173"); - assert!(csp.contains("ws://localhost:5173")); - assert!( - !csp.contains("ws://http://"), - "should not have double protocol" - ); - } - - #[test] - fn test_development_csp_https_uses_wss() { - let csp = development_csp("https://localhost:5173"); - assert!(csp.contains("wss://localhost:5173")); - assert!( - !csp.contains("ws://https://"), - "should not have double protocol" - ); - } - - #[test] - fn test_development_csp_rejects_invalid_origin_injection() { - let csp = development_csp("http://localhost:5173;script-src *"); - assert!(!csp.contains("localhost:5173;script-src")); - assert!(csp.contains("script-src 'self'")); - assert!(!csp.contains("script-src *")); - } - - #[test] - fn test_development_csp_rejects_whitespace_in_origin() { - let csp = development_csp("http://localhost :5173"); - assert!(!csp.contains("localhost :5173")); - } - - #[test] - fn test_production_csp_has_only_explicit_localhost_allowances() { - let csp = production_csp(); - assert!(csp.contains("https://volt.localhost")); - assert!(!csp.contains("http://localhost")); - assert!(!csp.contains("https://localhost")); - assert!(!csp.contains("ws://")); - } - #[test] fn test_path_empty_string() { // Empty string should be valid (it's a relative path with no components) diff --git a/crates/volt-runner/src/js_runtime/tests/native_ipc/roundtrip.rs b/crates/volt-runner/src/js_runtime/tests/native_ipc/roundtrip.rs index bb6e294..a02b21f 100644 --- a/crates/volt-runner/src/js_runtime/tests/native_ipc/roundtrip.rs +++ b/crates/volt-runner/src/js_runtime/tests/native_ipc/roundtrip.rs @@ -61,6 +61,24 @@ fn ipc_main_rejects_reserved_volt_channels() { assert!(error.contains("reserved by Volt")); } +#[test] +fn ipc_main_rejects_reserved_plugin_channels() { + let runtime = JsRuntimeManager::start().expect("js runtime start"); + let client = runtime.client(); + + let error = client + .eval_promise_string( + "(async () => { + const { ipcMain } = await import('volt:ipc'); + ipcMain.handle('plugin:acme.search:ping', () => ({ ok: true })); + return 'unreachable'; + })()", + ) + .expect_err("reserved plugin channel should be rejected"); + + assert!(error.contains("reserved by Volt")); +} + #[test] fn ipc_roundtrip_handles_reserved_native_fast_path_without_js_handler() { let runtime = JsRuntimeManager::start().expect("js runtime start"); diff --git a/crates/volt-runner/src/modules/volt_ipc.rs b/crates/volt-runner/src/modules/volt_ipc.rs index a8f6e2d..2c84f2b 100644 --- a/crates/volt-runner/src/modules/volt_ipc.rs +++ b/crates/volt-runner/src/modules/volt_ipc.rs @@ -27,7 +27,11 @@ const IPC_MODULE_BOOTSTRAP: &str = r#" }; const ensureUserChannel = (method) => { - if (method.startsWith('volt:')) { + if ( + method.startsWith('volt:') + || method.startsWith('__volt_internal:') + || method.startsWith('plugin:') + ) { throw new Error(`IPC channel is reserved by Volt: ${method}`); } return method; diff --git a/crates/volt-runner/src/plugin_manager/host_api_storage.rs b/crates/volt-runner/src/plugin_manager/host_api_storage.rs index 4d9bf0a..41f78ad 100644 --- a/crates/volt-runner/src/plugin_manager/host_api_storage.rs +++ b/crates/volt-runner/src/plugin_manager/host_api_storage.rs @@ -1,18 +1,13 @@ -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; +mod store; + +use std::path::PathBuf; -use serde::{Deserialize, Serialize}; use serde_json::Value; -use sha2::{Digest, Sha256}; use super::{PLUGIN_STORAGE_ERROR_CODE, PluginManager, PluginRuntimeError}; use crate::plugin_manager::host_api_helpers::lock_error; const STORAGE_DIR: &str = "storage"; -const STORAGE_INDEX_FILE: &str = "_index.json"; -const STORAGE_MAX_KEY_BYTES: usize = 256; -const STORAGE_MAX_VALUE_BYTES: usize = 1024 * 1024; -const STORAGE_MAX_TOTAL_BYTES: u64 = 100 * 1024 * 1024; impl PluginManager { pub(super) fn handle_storage_request( @@ -28,20 +23,23 @@ impl PluginManager { // will need an explicit per-plugin storage lock. let (storage_root, should_reconcile) = self.prepare_storage_root(plugin_id)?; let mut storage = - PluginStorage::open(&storage_root, should_reconcile).map_err(storage_error)?; + store::PluginStorage::open(&storage_root, should_reconcile).map_err(storage_error)?; match operation { "get" => Ok(storage - .get(required_key(payload)?)? + .get(store::required_key(payload)?)? .map(Value::String) .unwrap_or(Value::Null)), "set" => { - storage.set(required_key(payload)?, required_value(payload)?)?; + storage.set( + store::required_key(payload)?, + store::required_value(payload)?, + )?; Ok(Value::Null) } - "has" => Ok(Value::Bool(storage.has(required_key(payload)?)?)), + "has" => Ok(Value::Bool(storage.has(store::required_key(payload)?)?)), "delete" => { - storage.delete(required_key(payload)?)?; + storage.delete(store::required_key(payload)?)?; Ok(Value::Null) } "keys" => Ok(serde_json::json!(storage.keys())), @@ -72,240 +70,6 @@ impl PluginManager { } } -struct PluginStorage { - root: PathBuf, - index: StorageIndex, -} - -impl PluginStorage { - fn open(root: &Path, reconcile: bool) -> Result { - let mut index = load_index(root)?; - if reconcile { - reconcile_index(root, &mut index)?; - } - Ok(Self { - root: root.to_path_buf(), - index, - }) - } - - fn get(&self, key: String) -> Result, PluginRuntimeError> { - let Some(hash) = self.index.entries.get(&key) else { - return Ok(None); - }; - let path = value_path(hash); - match volt_core::fs::read_file_text(&self.root, &path) { - Ok(value) => Ok(Some(value)), - Err(volt_core::fs::FsError::Io(error)) - if error.kind() == std::io::ErrorKind::NotFound => - { - Ok(None) - } - Err(error) => Err(storage_error(error.to_string())), - } - } - - fn set(&mut self, key: String, value: String) -> Result<(), PluginRuntimeError> { - self.ensure_within_quota(&key, value.len() as u64)?; - let hash = hash_key(&key); - write_bytes_atomic(&self.root, &value_path(&hash), value.as_bytes()) - .map_err(storage_error)?; - self.index.entries.insert(key, hash); - save_index(&self.root, &self.index).map_err(storage_error) - } - - fn has(&self, key: String) -> Result { - Ok(self.get(key)?.is_some()) - } - - fn delete(&mut self, key: String) -> Result<(), PluginRuntimeError> { - let Some(hash) = self.index.entries.remove(&key) else { - return Ok(()); - }; - let value_path = value_path(&hash); - if volt_core::fs::exists(&self.root, &value_path) - .map_err(|error| storage_error(error.to_string()))? - { - volt_core::fs::remove(&self.root, &value_path) - .map_err(|error| storage_error(error.to_string()))?; - } - save_index(&self.root, &self.index).map_err(storage_error) - } - - fn keys(&self) -> Vec { - self.index.entries.keys().cloned().collect() - } - - fn ensure_within_quota( - &self, - key: &str, - next_value_bytes: u64, - ) -> Result<(), PluginRuntimeError> { - let current_total = self.total_value_bytes()?; - let replaced_bytes = self.value_bytes_for_key(key)?; - let projected_total = current_total - .saturating_sub(replaced_bytes) - .saturating_add(next_value_bytes); - if projected_total > STORAGE_MAX_TOTAL_BYTES { - return Err(storage_error(format!( - "storage quota exceeded ({} bytes > {} bytes)", - projected_total, STORAGE_MAX_TOTAL_BYTES - ))); - } - Ok(()) - } - - fn total_value_bytes(&self) -> Result { - let mut total = 0_u64; - for hash in self.index.entries.values() { - let path = self.root.join(value_path(hash)); - match std::fs::metadata(&path) { - Ok(metadata) => total = total.saturating_add(metadata.len()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(storage_error(error.to_string())), - } - } - Ok(total) - } - - fn value_bytes_for_key(&self, key: &str) -> Result { - let Some(hash) = self.index.entries.get(key) else { - return Ok(0); - }; - let path = self.root.join(value_path(hash)); - match std::fs::metadata(path) { - Ok(metadata) => Ok(metadata.len()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0), - Err(error) => Err(storage_error(error.to_string())), - } - } -} - -#[derive(Debug, Default, Serialize, Deserialize)] -struct StorageIndex { - entries: BTreeMap, -} - -fn load_index(root: &Path) -> Result { - match volt_core::fs::read_file_text(root, STORAGE_INDEX_FILE) { - Ok(contents) => match serde_json::from_str(&contents) { - Ok(index) => Ok(index), - Err(error) => { - tracing::warn!( - storage_root = %root.display(), - "plugin storage index is corrupted; rebuilding from an empty index: {error}" - ); - Ok(StorageIndex::default()) - } - }, - Err(volt_core::fs::FsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { - Ok(StorageIndex::default()) - } - Err(error) => Err(error.to_string()), - } -} - -fn save_index(root: &Path, index: &StorageIndex) -> Result<(), String> { - let bytes = serde_json::to_vec(index).map_err(|error| error.to_string())?; - write_bytes_atomic(root, STORAGE_INDEX_FILE, &bytes) -} - -fn reconcile_index(root: &Path, index: &mut StorageIndex) -> Result<(), String> { - let mut changed = false; - index.entries.retain(|_, hash| { - let exists = volt_core::fs::exists(root, &value_path(hash)).unwrap_or(false); - changed |= !exists; - exists - }); - - let expected = index - .entries - .values() - .map(|hash| value_path(hash)) - .collect::>(); - for entry in std::fs::read_dir(root).map_err(|error| error.to_string())? { - let entry = entry.map_err(|error| error.to_string())?; - let Some(name) = entry.file_name().to_str().map(str::to_string) else { - continue; - }; - if (name.ends_with(".val") && !expected.contains(&name)) || name.ends_with(".tmp") { - volt_core::fs::remove(root, &name).map_err(|error| error.to_string())?; - changed = true; - } - } - if changed { - save_index(root, index)?; - } - Ok(()) -} - -fn write_bytes_atomic(root: &Path, relative_path: &str, data: &[u8]) -> Result<(), String> { - let temp_path = temp_path(relative_path); - volt_core::fs::write_file(root, &temp_path, data).map_err(|error| error.to_string())?; - if let Err(error) = volt_core::fs::replace_file(root, &temp_path, relative_path) { - let _ = volt_core::fs::remove(root, &temp_path); - return Err(error.to_string()); - } - Ok(()) -} - -fn hash_key(key: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(key.as_bytes()); - format!("{:x}", hasher.finalize()) -} - -fn value_path(hash: &str) -> String { - format!("{hash}.val") -} - -fn temp_path(relative_path: &str) -> String { - relative_path - .replace(".val", ".tmp") - .replace(".json", ".tmp") -} - -fn required_key(payload: &Value) -> Result { - let key = payload - .get("key") - .and_then(Value::as_str) - .ok_or_else(|| storage_error("payload is missing required 'key' string"))?; - validate_key(key)?; - Ok(key.to_string()) -} - -fn required_value(payload: &Value) -> Result { - let value = payload - .get("value") - .and_then(Value::as_str) - .ok_or_else(|| storage_error("payload is missing required 'value' string"))?; - if value.len() > STORAGE_MAX_VALUE_BYTES { - return Err(storage_error(format!( - "storage value exceeds {} bytes", - STORAGE_MAX_VALUE_BYTES - ))); - } - Ok(value.to_string()) -} - -fn validate_key(key: &str) -> Result<(), PluginRuntimeError> { - if key.is_empty() { - return Err(storage_error("storage key must not be empty")); - } - if key.len() > STORAGE_MAX_KEY_BYTES { - return Err(storage_error(format!( - "storage key exceeds {} bytes", - STORAGE_MAX_KEY_BYTES - ))); - } - if key.contains("..") || key.contains('/') || key.contains('\\') { - return Err(storage_error( - "storage key must not contain path traversal segments", - )); - } - Ok(()) -} - fn storage_error(message: impl Into) -> PluginRuntimeError { PluginRuntimeError { code: PLUGIN_STORAGE_ERROR_CODE.to_string(), diff --git a/crates/volt-runner/src/plugin_manager/host_api_storage/store.rs b/crates/volt-runner/src/plugin_manager/host_api_storage/store.rs new file mode 100644 index 0000000..c13fccb --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/host_api_storage/store.rs @@ -0,0 +1,249 @@ +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::plugin_manager::PluginRuntimeError; + +use super::storage_error; + +const STORAGE_INDEX_FILE: &str = "_index.json"; +const STORAGE_MAX_KEY_BYTES: usize = 256; +const STORAGE_MAX_VALUE_BYTES: usize = 1024 * 1024; +const STORAGE_MAX_TOTAL_BYTES: u64 = 100 * 1024 * 1024; + +pub(super) struct PluginStorage { + root: PathBuf, + index: StorageIndex, +} + +impl PluginStorage { + pub(super) fn open(root: &Path, reconcile: bool) -> Result { + let mut index = load_index(root)?; + if reconcile { + reconcile_index(root, &mut index)?; + } + Ok(Self { + root: root.to_path_buf(), + index, + }) + } + + pub(super) fn get(&self, key: String) -> Result, PluginRuntimeError> { + let Some(hash) = self.index.entries.get(&key) else { + return Ok(None); + }; + let path = value_path(hash); + match volt_core::fs::read_file_text(&self.root, &path) { + Ok(value) => Ok(Some(value)), + Err(volt_core::fs::FsError::Io(error)) + if error.kind() == std::io::ErrorKind::NotFound => + { + Ok(None) + } + Err(error) => Err(storage_error(error.to_string())), + } + } + + pub(super) fn set(&mut self, key: String, value: String) -> Result<(), PluginRuntimeError> { + self.ensure_within_quota(&key, value.len() as u64)?; + let hash = hash_key(&key); + write_bytes_atomic(&self.root, &value_path(&hash), value.as_bytes()) + .map_err(storage_error)?; + self.index.entries.insert(key, hash); + save_index(&self.root, &self.index).map_err(storage_error) + } + + pub(super) fn has(&self, key: String) -> Result { + Ok(self.get(key)?.is_some()) + } + + pub(super) fn delete(&mut self, key: String) -> Result<(), PluginRuntimeError> { + let Some(hash) = self.index.entries.remove(&key) else { + return Ok(()); + }; + let value_path = value_path(&hash); + if volt_core::fs::exists(&self.root, &value_path) + .map_err(|error| storage_error(error.to_string()))? + { + volt_core::fs::remove(&self.root, &value_path) + .map_err(|error| storage_error(error.to_string()))?; + } + save_index(&self.root, &self.index).map_err(storage_error) + } + + pub(super) fn keys(&self) -> Vec { + self.index.entries.keys().cloned().collect() + } + + fn ensure_within_quota( + &self, + key: &str, + next_value_bytes: u64, + ) -> Result<(), PluginRuntimeError> { + let current_total = self.total_value_bytes()?; + let replaced_bytes = self.value_bytes_for_key(key)?; + let projected_total = current_total + .saturating_sub(replaced_bytes) + .saturating_add(next_value_bytes); + if projected_total > STORAGE_MAX_TOTAL_BYTES { + return Err(storage_error(format!( + "storage quota exceeded ({} bytes > {} bytes)", + projected_total, STORAGE_MAX_TOTAL_BYTES + ))); + } + Ok(()) + } + + fn total_value_bytes(&self) -> Result { + let mut total = 0_u64; + for hash in self.index.entries.values() { + let path = self.root.join(value_path(hash)); + match std::fs::metadata(&path) { + Ok(metadata) => total = total.saturating_add(metadata.len()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(storage_error(error.to_string())), + } + } + Ok(total) + } + + fn value_bytes_for_key(&self, key: &str) -> Result { + let Some(hash) = self.index.entries.get(key) else { + return Ok(0); + }; + let path = self.root.join(value_path(hash)); + match std::fs::metadata(path) { + Ok(metadata) => Ok(metadata.len()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0), + Err(error) => Err(storage_error(error.to_string())), + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct StorageIndex { + entries: BTreeMap, +} + +fn load_index(root: &Path) -> Result { + match volt_core::fs::read_file_text(root, STORAGE_INDEX_FILE) { + Ok(contents) => match serde_json::from_str(&contents) { + Ok(index) => Ok(index), + Err(error) => { + tracing::warn!( + storage_root = %root.display(), + "plugin storage index is corrupted; rebuilding from an empty index: {error}" + ); + Ok(StorageIndex::default()) + } + }, + Err(volt_core::fs::FsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(StorageIndex::default()) + } + Err(error) => Err(error.to_string()), + } +} + +fn save_index(root: &Path, index: &StorageIndex) -> Result<(), String> { + let bytes = serde_json::to_vec(index).map_err(|error| error.to_string())?; + write_bytes_atomic(root, STORAGE_INDEX_FILE, &bytes) +} + +fn reconcile_index(root: &Path, index: &mut StorageIndex) -> Result<(), String> { + let mut changed = false; + index.entries.retain(|_, hash| { + let exists = volt_core::fs::exists(root, &value_path(hash)).unwrap_or(false); + changed |= !exists; + exists + }); + + let expected = index + .entries + .values() + .map(|hash| value_path(hash)) + .collect::>(); + for entry in std::fs::read_dir(root).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + if (name.ends_with(".val") && !expected.contains(&name)) || name.ends_with(".tmp") { + volt_core::fs::remove(root, &name).map_err(|error| error.to_string())?; + changed = true; + } + } + if changed { + save_index(root, index)?; + } + Ok(()) +} + +fn write_bytes_atomic(root: &Path, relative_path: &str, data: &[u8]) -> Result<(), String> { + let temp_path = temp_path(relative_path); + volt_core::fs::write_file(root, &temp_path, data).map_err(|error| error.to_string())?; + if let Err(error) = volt_core::fs::replace_file(root, &temp_path, relative_path) { + let _ = volt_core::fs::remove(root, &temp_path); + return Err(error.to_string()); + } + Ok(()) +} + +fn hash_key(key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(key.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn value_path(hash: &str) -> String { + format!("{hash}.val") +} + +fn temp_path(relative_path: &str) -> String { + relative_path + .replace(".val", ".tmp") + .replace(".json", ".tmp") +} + +pub(super) fn required_key(payload: &Value) -> Result { + let key = payload + .get("key") + .and_then(Value::as_str) + .ok_or_else(|| storage_error("payload is missing required 'key' string"))?; + validate_key(key)?; + Ok(key.to_string()) +} + +pub(super) fn required_value(payload: &Value) -> Result { + let value = payload + .get("value") + .and_then(Value::as_str) + .ok_or_else(|| storage_error("payload is missing required 'value' string"))?; + if value.len() > STORAGE_MAX_VALUE_BYTES { + return Err(storage_error(format!( + "storage value exceeds {} bytes", + STORAGE_MAX_VALUE_BYTES + ))); + } + Ok(value.to_string()) +} + +fn validate_key(key: &str) -> Result<(), PluginRuntimeError> { + if key.is_empty() { + return Err(storage_error("storage key must not be empty")); + } + if key.len() > STORAGE_MAX_KEY_BYTES { + return Err(storage_error(format!( + "storage key exceeds {} bytes", + STORAGE_MAX_KEY_BYTES + ))); + } + if key.contains("..") || key.contains('/') || key.contains('\\') { + return Err(storage_error( + "storage key must not contain path traversal segments", + )); + } + Ok(()) +} diff --git a/crates/volt-runner/src/plugin_manager/process/child/handle.rs b/crates/volt-runner/src/plugin_manager/process/child/handle.rs index 453baac..e491f95 100644 --- a/crates/volt-runner/src/plugin_manager/process/child/handle.rs +++ b/crates/volt-runner/src/plugin_manager/process/child/handle.rs @@ -9,9 +9,10 @@ use serde_json::Value; use super::messaging::send_and_wait; use crate::plugin_manager::process::io::{ ChildPluginProcessInner, ExitState, ReadyState, spawn_exit_watcher, spawn_stderr_reader, - spawn_stdout_reader, wait_for_exit, write_wire_message, + spawn_stdout_reader, wait_for_exit, }; use crate::plugin_manager::process::wire::{WireMessage, WireMessageType}; +use crate::plugin_manager::process::wire_io::write_wire_message; use crate::plugin_manager::{ PLUGIN_HEARTBEAT_TIMEOUT_CODE, PLUGIN_RUNTIME_ERROR_CODE, PluginProcessHandle, PluginRuntimeError, ProcessExitInfo, @@ -75,7 +76,7 @@ impl PluginProcessHandle for ChildPluginProcess { } fn send_event(&self, method: &str, payload: Value) -> Result<(), PluginRuntimeError> { - crate::plugin_manager::process::io::write_wire_message( + crate::plugin_manager::process::wire_io::write_wire_message( &mut *self.inner.stdin.lock().map_err(|_| PluginRuntimeError { code: PLUGIN_RUNTIME_ERROR_CODE.to_string(), message: "plugin stdin is unavailable".to_string(), diff --git a/crates/volt-runner/src/plugin_manager/process/child/messaging.rs b/crates/volt-runner/src/plugin_manager/process/child/messaging.rs index bf04c28..5287512 100644 --- a/crates/volt-runner/src/plugin_manager/process/child/messaging.rs +++ b/crates/volt-runner/src/plugin_manager/process/child/messaging.rs @@ -1,8 +1,9 @@ use std::sync::{Arc, mpsc}; use std::time::Duration; -use crate::plugin_manager::process::io::{ChildPluginProcessInner, write_wire_message}; +use crate::plugin_manager::process::io::ChildPluginProcessInner; use crate::plugin_manager::process::wire::WireMessage; +use crate::plugin_manager::process::wire_io::write_wire_message; use crate::plugin_manager::{PLUGIN_RUNTIME_ERROR_CODE, PluginRuntimeError}; pub(super) fn send_and_wait( diff --git a/crates/volt-runner/src/plugin_manager/process/io.rs b/crates/volt-runner/src/plugin_manager/process/io.rs index 466cd41..af7407e 100644 --- a/crates/volt-runner/src/plugin_manager/process/io.rs +++ b/crates/volt-runner/src/plugin_manager/process/io.rs @@ -1,17 +1,16 @@ use std::collections::HashMap; -use std::io::{BufReader, BufWriter, Read, Write}; +use std::io::{BufReader, BufWriter, Read}; use std::process::{Child, ChildStdin, ChildStdout}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex, mpsc}; use std::thread; use std::time::Duration; +use super::stderr_capture::read_bounded_stderr; use super::wire::{WireMessage, WireMessageType}; +use super::wire_io::read_wire_message; use crate::plugin_manager::{ExitListener, MessageListener, ProcessExitInfo}; -const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024; -const MAX_STDERR_CAPTURE_BYTES: usize = 256 * 1024; - pub(super) struct ChildPluginProcessInner { pub(super) child: Mutex, pub(super) stdin: Mutex>, @@ -176,25 +175,6 @@ pub(super) fn wait_for_exit(exit_state: &ExitState, timeout: Duration) -> Option info.clone() } -pub(super) fn write_wire_message( - writer: &mut W, - message: &WireMessage, -) -> std::io::Result<()> { - let json = serde_json::to_string(message) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; - let body = format!("{json}\n"); - let bytes = body.as_bytes(); - if bytes.len() > MAX_FRAME_SIZE { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("frame too large: {}", bytes.len()), - )); - } - writer.write_all(&(bytes.len() as u32).to_le_bytes())?; - writer.write_all(bytes)?; - writer.flush() -} - fn read_plugin_stdout(process: Arc, stdout: ChildStdout) { let mut reader = BufReader::new(stdout); loop { @@ -230,7 +210,7 @@ fn read_plugin_stdout(process: Arc, stdout: ChildStdout .stdin .lock() .ok() - .map(|mut stdin| write_wire_message(&mut *stdin, &response)); + .map(|mut stdin| super::wire_io::write_wire_message(&mut *stdin, &response)); } } } @@ -255,77 +235,10 @@ pub(super) fn drain_waiters(waiters: &Mutex(reader: &mut BufReader) -> std::io::Result> { - let mut len_buf = [0_u8; 4]; - match reader.read_exact(&mut len_buf) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), - Err(error) => return Err(error), - } - - let length = u32::from_le_bytes(len_buf) as usize; - if length == 0 || length > MAX_FRAME_SIZE { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("invalid frame length: {length}"), - )); - } - - let mut body = vec![0_u8; length]; - reader.read_exact(&mut body)?; - let raw = String::from_utf8(body) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; - let trimmed = raw.trim_end_matches('\n'); - serde_json::from_str(trimmed) - .map(Some) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) -} - -fn read_bounded_stderr(stderr: &mut impl Read) -> String { - let mut captured = Vec::with_capacity(4096); - let mut chunk = [0_u8; 8192]; - let mut truncated = false; - - loop { - match stderr.read(&mut chunk) { - Ok(0) => break, - Ok(read) => { - let remaining = MAX_STDERR_CAPTURE_BYTES.saturating_sub(captured.len()); - if remaining == 0 { - truncated = true; - break; - } - let to_copy = read.min(remaining); - captured.extend_from_slice(&chunk[..to_copy]); - if to_copy < read { - truncated = true; - break; - } - } - Err(_) => break, - } - } - - let mut text = String::from_utf8_lossy(&captured).into_owned(); - if truncated { - text.push_str("\n[volt] plugin stderr truncated at 262144 bytes"); - } - text -} #[cfg(test)] mod tests { use super::*; - #[test] - fn bounded_stderr_reader_caps_capture_size() { - let oversized = vec![b'x'; MAX_STDERR_CAPTURE_BYTES + 1024]; - let mut reader = std::io::Cursor::new(oversized); - let captured = read_bounded_stderr(&mut reader); - - assert!(captured.len() >= MAX_STDERR_CAPTURE_BYTES); - assert!(captured.contains("plugin stderr truncated")); - } - #[test] fn drain_waiters_disconnects_pending_receivers() { let waiters = Mutex::new(HashMap::new()); diff --git a/crates/volt-runner/src/plugin_manager/process/mod.rs b/crates/volt-runner/src/plugin_manager/process/mod.rs index ef3f9fd..c18c628 100644 --- a/crates/volt-runner/src/plugin_manager/process/mod.rs +++ b/crates/volt-runner/src/plugin_manager/process/mod.rs @@ -1,6 +1,8 @@ mod child; mod io; +mod stderr_capture; mod wire; +mod wire_io; pub(crate) use self::child::RealPluginProcessFactory; pub(crate) use self::wire::{WireError, WireMessage, WireMessageType}; diff --git a/crates/volt-runner/src/plugin_manager/process/stderr_capture.rs b/crates/volt-runner/src/plugin_manager/process/stderr_capture.rs new file mode 100644 index 0000000..c6289a6 --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/process/stderr_capture.rs @@ -0,0 +1,50 @@ +use std::io::Read; + +const MAX_STDERR_CAPTURE_BYTES: usize = 256 * 1024; + +pub(super) fn read_bounded_stderr(stderr: &mut impl Read) -> String { + let mut captured = Vec::with_capacity(4096); + let mut chunk = [0_u8; 8192]; + let mut truncated = false; + + loop { + match stderr.read(&mut chunk) { + Ok(0) => break, + Ok(read) => { + let remaining = MAX_STDERR_CAPTURE_BYTES.saturating_sub(captured.len()); + if remaining == 0 { + truncated = true; + break; + } + let to_copy = read.min(remaining); + captured.extend_from_slice(&chunk[..to_copy]); + if to_copy < read { + truncated = true; + break; + } + } + Err(_) => break, + } + } + + let mut text = String::from_utf8_lossy(&captured).into_owned(); + if truncated { + text.push_str("\n[volt] plugin stderr truncated at 262144 bytes"); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounded_stderr_reader_caps_capture_size() { + let oversized = vec![b'x'; MAX_STDERR_CAPTURE_BYTES + 1024]; + let mut reader = std::io::Cursor::new(oversized); + let captured = read_bounded_stderr(&mut reader); + + assert!(captured.len() >= MAX_STDERR_CAPTURE_BYTES); + assert!(captured.contains("plugin stderr truncated")); + } +} diff --git a/crates/volt-runner/src/plugin_manager/process/wire_io.rs b/crates/volt-runner/src/plugin_manager/process/wire_io.rs new file mode 100644 index 0000000..410365e --- /dev/null +++ b/crates/volt-runner/src/plugin_manager/process/wire_io.rs @@ -0,0 +1,52 @@ +use std::io::{BufReader, Read, Write}; + +use super::wire::WireMessage; + +const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024; + +pub(super) fn write_wire_message( + writer: &mut W, + message: &WireMessage, +) -> std::io::Result<()> { + let json = serde_json::to_string(message) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + let body = format!("{json}\n"); + let bytes = body.as_bytes(); + if bytes.len() > MAX_FRAME_SIZE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("frame too large: {}", bytes.len()), + )); + } + writer.write_all(&(bytes.len() as u32).to_le_bytes())?; + writer.write_all(bytes)?; + writer.flush() +} + +pub(super) fn read_wire_message( + reader: &mut BufReader, +) -> std::io::Result> { + let mut len_buf = [0_u8; 4]; + match reader.read_exact(&mut len_buf) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(error) => return Err(error), + } + + let length = u32::from_le_bytes(len_buf) as usize; + if length == 0 || length > MAX_FRAME_SIZE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid frame length: {length}"), + )); + } + + let mut body = vec![0_u8; length]; + reader.read_exact(&mut body)?; + let raw = String::from_utf8(body) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + let trimmed = raw.trim_end_matches('\n'); + serde_json::from_str(trimmed) + .map(Some) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) +} diff --git a/packages/volt/src/__tests__/ipc.test.ts b/packages/volt/src/__tests__/ipc.test.ts index 1c124c4..87c08b7 100644 --- a/packages/volt/src/__tests__/ipc.test.ts +++ b/packages/volt/src/__tests__/ipc.test.ts @@ -51,6 +51,12 @@ describe('ipcMain', () => { ); }); + it('handle rejects reserved plugin channels', () => { + expect(() => ipcMain.handle('plugin:acme.search:ping', () => 'nope')).toThrow( + 'reserved by Volt', + ); + }); + it('removeHandler removes a handler', () => { ipcMain.handle('test-channel', () => 'x'); ipcMain.removeHandler('test-channel'); diff --git a/packages/volt/src/ipc.ts b/packages/volt/src/ipc.ts index 709f4a1..c207e82 100644 --- a/packages/volt/src/ipc.ts +++ b/packages/volt/src/ipc.ts @@ -6,7 +6,7 @@ type IpcHandler = (args: unknown) => Promise | unknown; const handlers = new Map(); -const RESERVED_IPC_PREFIXES = ['volt:', '__volt_internal:']; +const RESERVED_IPC_PREFIXES = ['volt:', '__volt_internal:', 'plugin:']; export type IpcErrorCode = | 'IPC_HANDLER_NOT_FOUND'