diff --git a/README.md b/README.md index c3b38b0..931e5b1 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,19 @@ wasi-virt component.wasm -e CUSTOM=VAR --allow-env -o virt.wasm wasi-virt component.wasm -e CUSTOM=VAR --allow-env=SOME,ENV_VARS -o virt.wasm ``` +### Config + +```sh +# Setting specific config properties (while disallowing all host config property access): +wasi-virt component.wasm -c CUSTOM=VAR -o virt.wasm + +# Setting config properties with all host config properties allowed: +wasi-virt component.wasm -c CUSTOM=VAR --allow-config -o virt.wasm + +# Setting config properties with restricted host config property access: +wasi-virt component.wasm -c CUSTOM=VAR --allow-config=SOME,PROPERTY -o virt.wasm +``` + ### Exit ```sh diff --git a/lib/package.wasm b/lib/package.wasm index 1f57d57..87f7859 100644 Binary files a/lib/package.wasm and b/lib/package.wasm differ diff --git a/lib/virtual_adapter.debug.wasm b/lib/virtual_adapter.debug.wasm index 85b8a13..6cf25b9 100755 Binary files a/lib/virtual_adapter.debug.wasm and b/lib/virtual_adapter.debug.wasm differ diff --git a/lib/virtual_adapter.wasm b/lib/virtual_adapter.wasm index cd92b49..c5378a7 100755 Binary files a/lib/virtual_adapter.wasm and b/lib/virtual_adapter.wasm differ diff --git a/src/bin/wasi-virt.rs b/src/bin/wasi-virt.rs index 256749e..107a8cf 100644 --- a/src/bin/wasi-virt.rs +++ b/src/bin/wasi-virt.rs @@ -59,6 +59,15 @@ struct Args { #[arg(short, long, use_value_delimiter(true), value_name("ENV=VAR"), value_parser = parse_key_val::, help_heading = "Env")] env: Option>, + // CONFIG + /// Allow unrestricted access to host configuration properties, or to a comma-separated list of property names. + #[arg(long, num_args(0..), use_value_delimiter(true), require_equals(true), value_name("PROPERTY_NAME"), help_heading = "Config")] + allow_config: Option>, + + /// Set config property overrides + #[arg(short, long, use_value_delimiter(true), value_name("NAME=VALUE"), value_parser = parse_key_val::, help_heading = "Config")] + config: Option>, + // FS /// Allow unrestricted access to host preopens #[arg(long, default_missing_value="true", num_args=0..=1, help_heading = "Fs")] @@ -173,6 +182,25 @@ fn main() -> Result<()> { env.overrides = env_overrides; } + // config options + let config = virt_opts.config(); + match args.allow_config { + Some(allow_config) if allow_config.len() == 0 => { + config.allow_all(); + } + Some(allow_config) => { + config.allow(&allow_config); + } + None => { + if allow_all { + config.allow_all(); + } + } + }; + if let Some(config_overrides) = args.config { + config.overrides = config_overrides; + } + // fs options let fs = virt_opts.fs(); if let Some(preopens) = args.preopen { diff --git a/src/lib.rs b/src/lib.rs index ce83938..a222a2c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use serde::Deserialize; use std::env; +use virt_config::{create_config_virt, strip_config_virt}; use virt_deny::{ deny_clocks_virt, deny_exit_virt, deny_http_virt, deny_random_virt, deny_sockets_virt, }; @@ -12,12 +13,14 @@ use wit_component::{metadata, ComponentEncoder, DecodedWasm, StringEncoding}; mod data; mod stub_preview1; +mod virt_config; mod virt_deny; mod virt_env; mod virt_io; mod walrus_ops; pub use stub_preview1::stub_preview1; +pub use virt_config::{HostConfig, VirtConfig}; pub use virt_env::{HostEnv, VirtEnv}; pub use virt_io::{FsEntry, StdioCfg, VirtFs, VirtualFiles}; @@ -42,6 +45,8 @@ pub struct WasiVirt { pub debug: bool, /// Environment virtualization pub env: Option, + /// Configuration virtualization + pub config: Option, /// Filesystem virtualization pub fs: Option, /// Stdio virtualization @@ -77,6 +82,7 @@ impl WasiVirt { self.exit(true); self.random(true); self.env().allow_all(); + self.config().allow_all(); self.fs().allow_host_preopens(); self.stdio().allow(); } @@ -88,6 +94,7 @@ impl WasiVirt { self.exit(false); self.random(false); self.env().deny_all(); + self.config().deny_all(); self.fs().deny_host_preopens(); self.stdio().ignore(); } @@ -116,6 +123,10 @@ impl WasiVirt { self.env.get_or_insert_with(Default::default) } + pub fn config(&mut self) -> &mut VirtConfig { + self.config.get_or_insert_with(Default::default) + } + pub fn fs(&mut self) -> &mut VirtFs { self.fs.get_or_insert_with(Default::default) } @@ -138,10 +149,13 @@ impl WasiVirt { }?; module.name = Some("wasi_virt".into()); - // only env virtualization is independent of io + // only env and config virtualization are independent of io if let Some(env) = &self.env { create_env_virt(&mut module, env)?; } + if let Some(config) = &self.config { + create_config_virt(&mut module, config)?; + } let has_io = self.fs.is_some() || self.stdio.is_some() @@ -191,6 +205,7 @@ impl WasiVirt { let base_world = resolve.select_world(&pkg_ids, Some("virtual-base"))?; let env_world = resolve.select_world(&pkg_ids, Some("virtual-env"))?; + let config_world = resolve.select_world(&pkg_ids, Some("virtual-config"))?; let io_world = resolve.select_world(&pkg_ids, Some("virtual-io"))?; let io_clocks_world = resolve.select_world(&pkg_ids, Some("virtual-io-clocks"))?; @@ -205,12 +220,17 @@ impl WasiVirt { let http_world = resolve.select_world(&pkg_ids, Some("virtual-http"))?; let sockets_world = resolve.select_world(&pkg_ids, Some("virtual-sockets"))?; - // env, exit & random subsystems are fully independent + // env, config, exit & random subsystems are fully independent if self.env.is_some() { resolve.merge_worlds(env_world, base_world)?; } else { strip_env_virt(&mut module)?; } + if self.config.is_some() { + resolve.merge_worlds(config_world, base_world)?; + } else { + strip_config_virt(&mut module)?; + } if let Some(exit) = self.exit { if !exit { resolve.merge_worlds(exit_world, base_world)?; diff --git a/src/virt_config.rs b/src/virt_config.rs new file mode 100644 index 0000000..5603457 --- /dev/null +++ b/src/virt_config.rs @@ -0,0 +1,252 @@ +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use walrus::{ + ir::Value, ActiveData, ActiveDataLocation, ConstExpr, DataKind, ExportItem, GlobalKind, Module, +}; + +use crate::walrus_ops::{bump_stack_global, get_active_data_segment}; + +#[derive(Deserialize, Debug, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct VirtConfig { + /// Set specific configuration property overrides + #[serde(default)] + pub overrides: Vec<(String, String)>, + /// Define how to embed into the host configuration + /// (Pass-through / encapsulate / allow / deny) + #[serde(default)] + pub host: HostConfig, +} + +#[derive(Deserialize, Debug, Clone, Default)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub enum HostConfig { + /// Fully encapsulate the configuration, removing all host + /// configuration import checks + #[default] + None, + /// Apart from the overrides, pass through all configuration + /// properties from the host + All, + /// Only allow the provided configuration property keys + Allow(Vec), + /// Allow all configuration properties, except the provided keys + Deny(Vec), +} + +impl VirtConfig { + /// Set the host configuration property allow list + pub fn allow(&mut self, allow_list: &[String]) -> &mut Self { + self.host = HostConfig::Allow(allow_list.iter().map(|s| s.to_string()).collect()); + self + } + + /// Set the host configuration property deny list + pub fn deny(&mut self, deny_list: &[&str]) -> &mut Self { + self.host = HostConfig::Deny(deny_list.iter().map(|s| s.to_string()).collect()); + self + } + + /// Enable all configuration properties on the host + pub fn allow_all(&mut self) -> &mut Self { + self.host = HostConfig::All; + self + } + + /// Deny all configuration properties on the host + pub fn deny_all(&mut self) -> &mut Self { + self.host = HostConfig::None; + self + } + + /// Set the configuration property overrides + pub fn overrides(&mut self, overrides: &[(&str, &str)]) -> &mut Self { + for (key, val) in overrides { + self.overrides.push((key.to_string(), val.to_string())); + } + self + } +} + +pub(crate) fn create_config_virt<'a>(module: &'a mut Module, config: &VirtConfig) -> Result<()> { + let config_ptr_addr = { + let config_ptr_export = module + .exports + .iter() + .find(|expt| expt.name.as_str() == "config") + .context("Adapter 'config' is not exported")?; + let ExportItem::Global(config_ptr_global) = config_ptr_export.item else { + bail!("Adapter 'config' not a global"); + }; + let GlobalKind::Local(ConstExpr::Value(Value::I32(config_ptr_addr))) = + &module.globals.get(config_ptr_global).kind + else { + bail!("Adapter 'config' not a local I32 global value"); + }; + *config_ptr_addr as u32 + }; + + // If host config is disabled, remove its import entirely + // replacing it with a stub panic + if matches!(config.host, HostConfig::None) { + stub_config_virt(module)?; + // we do arguments as well because virt assumes reactors for now... + } + + let memory = module.get_memory_id()?; + + // prepare the field data list vector for writing + // strings must be sorted as binary searches are used against this data + let mut field_data_vec: Vec<&str> = Vec::new(); + let mut sorted_overrides = config.overrides.clone(); + sorted_overrides.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in &sorted_overrides { + field_data_vec.push(key.as_ref()); + field_data_vec.push(value.as_ref()); + } + match &config.host { + HostConfig::Allow(allow_list) => { + let mut allow_list: Vec<&str> = allow_list.iter().map(|item| item.as_ref()).collect(); + allow_list.sort(); + for key in allow_list { + field_data_vec.push(key); + } + } + HostConfig::Deny(deny_list) => { + let mut deny_list: Vec<&str> = deny_list.iter().map(|item| item.as_ref()).collect(); + deny_list.sort(); + for key in deny_list { + field_data_vec.push(key); + } + } + _ => {} + } + + let mut field_data_bytes = Vec::new(); + for str in field_data_vec { + assert!(field_data_bytes.len() % 4 == 0); + // write the length at the aligned offset + field_data_bytes.extend_from_slice(&(str.len() as u32).to_le_bytes()); + let str_bytes = str.as_bytes(); + field_data_bytes.extend_from_slice(str_bytes); + let rem = str_bytes.len() % 4; + // add padding for alignment if necessary + if rem > 0 { + field_data_bytes.extend((0..4 - rem).map(|_| 0)); + } + } + + if field_data_bytes.len() % 8 != 0 { + field_data_bytes.resize(field_data_bytes.len() + 4, 0); + } + + let field_data_addr = if field_data_bytes.len() > 0 { + // Offset the stack global by the static field data length + let field_data_addr = bump_stack_global(module, field_data_bytes.len() as i32)?; + + // Add a new data segment for this new range created at the top of the stack + module.data.add( + DataKind::Active(ActiveData { + memory, + location: ActiveDataLocation::Absolute(field_data_addr), + }), + field_data_bytes, + ); + Some(field_data_addr) + } else { + None + }; + + // In the existing static data segment, update the static data options. + // + // From virtual-adapter/src/config.rs: + // + // #[repr(C)] + // pub struct Config { + // /// Whether to fallback to the host config + // /// [byte 0] + // host_fallback: bool, + // /// Whether we are providing an allow list or a deny list + // /// on the fallback lookups + // /// [byte 1] + // host_fallback_allow: bool, + // /// How many host fields are defined in the data pointer + // /// [byte 4] + // host_field_cnt: u32, + // /// Host many host fields are defined to be allow or deny + // /// (these are concatenated at the end of the data with empty values) + // /// [byte 8] + // host_allow_or_deny_cnt: u32, + // /// Byte data of u32 byte len followed by string bytes + // /// up to the lengths previously provided. + // /// [byte 12] + // host_field_data: *const u8, + // } + let (data, data_offset) = get_active_data_segment(module, memory, config_ptr_addr)?; + let bytes = data.value.as_mut_slice(); + + let host_field_cnt = config.overrides.len() as u32; + bytes[data_offset + 4..data_offset + 8].copy_from_slice(&host_field_cnt.to_le_bytes()); + match &config.host { + // All is already the default data + HostConfig::All => {} + HostConfig::None => { + bytes[data_offset] = 0; + } + HostConfig::Allow(allow_list) => { + bytes[data_offset + 1] = 1; + bytes[data_offset + 8..data_offset + 12] + .copy_from_slice(&(allow_list.len() as u32).to_le_bytes()); + } + HostConfig::Deny(deny_list) => { + bytes[data_offset + 1] = 0; + bytes[data_offset + 8..data_offset + 12] + .copy_from_slice(&(deny_list.len() as u32).to_le_bytes()); + } + }; + if let Some(field_data_addr) = field_data_addr { + bytes[data_offset + 12..data_offset + 16].copy_from_slice(&field_data_addr.to_le_bytes()); + } + + Ok(()) +} + +/// Functions that represent the configuration functionality provided by WASI CLI +const WASI_CONFIG_FNS: [&str; 2] = ["get", "get-all"]; + +/// Stub imported functions that implement the WASI runtime config functionality +/// +/// This function throws an error if any imported functions do not exist +pub(crate) fn stub_config_virt(module: &mut Module) -> Result<()> { + for fn_name in WASI_CONFIG_FNS { + module.replace_imported_func( + module + .imports + .get_func("wasi:config/runtime@0.2.0-draft", fn_name)?, + |(body, _)| { + body.unreachable(); + }, + )?; + } + + Ok(()) +} + +/// Strip exported functions that implement the WASI runtime config functionality +pub(crate) fn strip_config_virt(module: &mut Module) -> Result<()> { + stub_config_virt(module)?; + + for fn_name in WASI_CONFIG_FNS { + let Ok(fid) = module + .exports + .get_func(format!("wasi:config/runtime@0.2.0-draft#{fn_name}")) + else { + bail!("Expected CLI function {fn_name}") + }; + module.replace_exported_func(fid, |(body, _)| { + body.unreachable(); + })?; + } + + Ok(()) +} diff --git a/virtual-adapter/src/config.rs b/virtual-adapter/src/config.rs new file mode 100644 index 0000000..a7d6324 --- /dev/null +++ b/virtual-adapter/src/config.rs @@ -0,0 +1,118 @@ +use crate::exports::wasi::config::runtime::{ConfigError, Guest as Runtime}; +use crate::wasi::config::runtime; +use crate::VirtAdapter; + +#[repr(C)] +pub struct Config { + /// Whether to fallback to the host config + /// [byte 0] + host_fallback: bool, + /// Whether we are providing an allow list or a deny list + /// on the fallback lookups + /// [byte 1] + host_fallback_allow: bool, + /// How many host fields are defined in the data pointer + /// [byte 4] + host_field_cnt: u32, + /// Host many host fields are defined to be allow or deny + /// (these are concatenated at the end of the data with empty values) + /// [byte 8] + host_allow_or_deny_cnt: u32, + /// Byte data of u32 byte len followed by string bytes + /// up to the lengths previously provided. + /// [byte 12] + host_field_data: *const u8, +} + +#[no_mangle] +pub static mut config: Config = Config { + host_fallback: true, + host_fallback_allow: false, + host_field_cnt: 0, + host_allow_or_deny_cnt: 0, + host_field_data: 0 as *const u8, +}; + +fn read_data_str(offset: &mut isize) -> &'static str { + let data: *const u8 = unsafe { config.host_field_data.offset(*offset) }; + let byte_len = unsafe { (data as *const u32).read() } as usize; + *offset += 4; + let data: *const u8 = unsafe { config.host_field_data.offset(*offset) }; + let str_data = unsafe { std::slice::from_raw_parts(data, byte_len) }; + *offset += byte_len as isize; + let rem = *offset % 4; + if rem > 0 { + *offset += 4 - rem; + } + unsafe { core::str::from_utf8_unchecked(str_data) } +} + +impl Runtime for VirtAdapter { + fn get(key: String) -> Result, ConfigError> { + let mut data_offset: isize = 0; + for _ in 0..unsafe { config.host_field_cnt } { + let config_key = read_data_str(&mut data_offset); + let config_val = read_data_str(&mut data_offset); + if key == config_key.to_string() { + return Ok(Some(config_val.to_string())); + } + } + + // fallback ASSUMES that all data is alphabetically ordered + if unsafe { config.host_fallback } { + let mut allow_or_deny = Vec::new(); + for _ in 0..unsafe { config.host_allow_or_deny_cnt } { + let allow_or_deny_key = read_data_str(&mut data_offset); + allow_or_deny.push(allow_or_deny_key); + } + + let is_allow_list = unsafe { config.host_fallback_allow }; + let in_list = allow_or_deny.binary_search(&key.as_ref()).is_ok(); + if is_allow_list && in_list || !is_allow_list && !in_list { + return runtime::get(&key).map_err(config_err_map); + } + } + Ok(None) + } + + fn get_all() -> Result, ConfigError> { + let mut configuration = Vec::new(); + let mut data_offset: isize = 0; + for _ in 0..unsafe { config.host_field_cnt } { + let config_key = read_data_str(&mut data_offset); + let config_val = read_data_str(&mut data_offset); + configuration.push((config_key.to_string(), config_val.to_string())); + } + let override_len = configuration.len(); + // fallback ASSUMES that all data is alphabetically ordered + if unsafe { config.host_fallback } { + let mut allow_or_deny = Vec::new(); + for _ in 0..unsafe { config.host_allow_or_deny_cnt } { + let allow_or_deny_key = read_data_str(&mut data_offset); + allow_or_deny.push(allow_or_deny_key); + } + + let is_allow_list = unsafe { config.host_fallback_allow }; + for (key, value) in runtime::get_all().map_err(config_err_map)? { + if configuration[0..override_len] + .binary_search_by_key(&&key, |(s, _)| s) + .is_ok() + { + continue; + } + let in_list = allow_or_deny.binary_search(&key.as_ref()).is_ok(); + if is_allow_list && in_list || !is_allow_list && !in_list { + configuration.push((key, value)); + } + } + } + Ok(configuration) + } +} + +fn config_err_map(err: runtime::ConfigError) -> ConfigError { + match err { + runtime::ConfigError::Upstream(msg) => ConfigError::Upstream(msg), + runtime::ConfigError::Io(msg) => ConfigError::Io(msg), + } +} diff --git a/virtual-adapter/src/lib.rs b/virtual-adapter/src/lib.rs index b328a9f..aab68d3 100644 --- a/virtual-adapter/src/lib.rs +++ b/virtual-adapter/src/lib.rs @@ -1,6 +1,7 @@ #![no_main] #![feature(ptr_sub_ptr)] +mod config; mod env; mod io; diff --git a/wasi-version b/wasi-version new file mode 100644 index 0000000..dc99f44 --- /dev/null +++ b/wasi-version @@ -0,0 +1 @@ +25fcf41c064d9899 \ No newline at end of file diff --git a/wit/deps/config/runtime_config.wit b/wit/deps/config/runtime_config.wit new file mode 100644 index 0000000..29d53b1 --- /dev/null +++ b/wit/deps/config/runtime_config.wit @@ -0,0 +1,25 @@ +interface runtime { + /// An error type that encapsulates the different errors that can occur fetching config + variant config-error { + /// This indicates an error from an "upstream" config source. + /// As this could be almost _anything_ (such as Vault, Kubernetes ConfigMaps, KeyValue buckets, etc), + /// the error message is a string. + upstream(string), + /// This indicates an error from an I/O operation. + /// As this could be almost _anything_ (such as a file read, network connection, etc), + /// the error message is a string. + /// Depending on how this ends up being consumed, + /// we may consider moving this to use the `wasi:io/error` type instead. + /// For simplicity right now in supporting multiple implementations, it is being left as a string. + io(string), + } + + /// Gets a single opaque config value set at the given key if it exists + get: func( + /// A string key to fetch + key: string + ) -> result, config-error>; + + /// Gets a list of all set config data + get-all: func() -> result>, config-error>; +} diff --git a/wit/deps/config/world.wit b/wit/deps/config/world.wit new file mode 100644 index 0000000..378c4a7 --- /dev/null +++ b/wit/deps/config/world.wit @@ -0,0 +1,6 @@ +package wasi:config@0.2.0-draft; + +world imports { + /// The runtime interface for config + import runtime; +} \ No newline at end of file diff --git a/wit/virt.wit b/wit/virt.wit index 7ed1cfb..563fcf4 100644 --- a/wit/virt.wit +++ b/wit/virt.wit @@ -43,6 +43,8 @@ world virtual-adapter { export wasi:sockets/tcp@0.2.0; import wasi:sockets/udp@0.2.0; export wasi:sockets/udp@0.2.0; + import wasi:config/runtime@0.2.0-draft; + export wasi:config/runtime@0.2.0-draft; } world virtual-base { @@ -160,6 +162,11 @@ world virtual-exit { export wasi:cli/exit@0.2.0; } +world virtual-config { + import wasi:config/runtime@0.2.0-draft; + export wasi:config/runtime@0.2.0-draft; +} + world virt-test { import wasi:clocks/wall-clock@0.2.0; import wasi:clocks/monotonic-clock@0.2.0;