diff --git a/Cargo.lock b/Cargo.lock index 0ccc6f3d74..6684100ed6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8334,6 +8334,7 @@ name = "spin-build" version = "3.7.0-pre0" dependencies = [ "anyhow", + "indexmap 2.12.0", "serde", "spin-common", "spin-environments", @@ -8475,6 +8476,7 @@ dependencies = [ "spin-app", "spin-common", "spin-componentize", + "spin-env-isolator", "spin-serde", "thiserror 2.0.17", "tokio", @@ -8520,6 +8522,16 @@ dependencies = [ "ui-testing", ] +[[package]] +name = "spin-env-isolator" +version = "3.7.0-pre0" +dependencies = [ + "anyhow", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", + "wasmtime", +] + [[package]] name = "spin-environments" version = "3.7.0-pre0" @@ -9005,6 +9017,7 @@ dependencies = [ "serde_json", "sha2", "spin-common", + "spin-env-isolator", "spin-expressions", "spin-locked-app", "spin-manifest", diff --git a/Makefile b/Makefile index 730e20303c..d23860d071 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ lint-rust-examples: for manifest_path in $$(find examples -name Cargo.toml); do \ echo "Linting $${manifest_path}" \ && cargo clippy --manifest-path "$${manifest_path}" -- -D warnings \ - && cargo fmt --manifest-path "$${manifest_path}" -- --check \ + && cargo fmt --manifest-path "$${manifest_path}" --all -- --check \ || exit 1 ; \ done diff --git a/crates/build/Cargo.toml b/crates/build/Cargo.toml index ecedecb9ab..bb7ca16a31 100644 --- a/crates/build/Cargo.toml +++ b/crates/build/Cargo.toml @@ -6,6 +6,7 @@ edition = { workspace = true } [dependencies] anyhow = { workspace = true } +indexmap = { workspace = true } serde = { workspace = true } spin-common = { path = "../common" } spin-environments = { path = "../environments" } diff --git a/crates/build/src/lib.rs b/crates/build/src/lib.rs index 6b517896e2..994e1e0897 100644 --- a/crates/build/src/lib.rs +++ b/crates/build/src/lib.rs @@ -432,6 +432,7 @@ mod tests { let dep = v2::ComponentDependency::Local { path: path.into(), export: None, + environment: indexmap::IndexMap::new(), }; deps.push((dep_name, dep)); } diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 9672c6a4bb..16f9bfaae0 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -16,6 +16,7 @@ semver = { workspace = true } spin-app = { path = "../app" } spin-common = { path = "../common" } spin-componentize = { path = "../componentize" } +spin-env-isolator = { path = "../env-isolator" } spin-serde = { path = "../serde" } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs"] } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index eb1b4edcbc..636f802a00 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -213,6 +213,12 @@ impl<'a, L: ComponentSourceLoader> Composer<'a, L> { let prepared = self.prepare_dependencies(world_id, component).await?; + // Apply env isolation when there is at least one dependency. + if self.should_apply_env_isolation(&prepared) { + self.apply_env_isolation(component.id(), instantiation_id, world_id, &prepared) + .map_err(ComposeError::PrepareError)?; + } + let arguments = self .build_instantiation_arguments(world_id, prepared) .await?; @@ -466,6 +472,115 @@ impl<'a, L: ComponentSourceLoader> Composer<'a, L> { Ok(()) } + + /// Detect the WASI CLI environment version from a registered component's imports. + fn detect_wasi_env_version(&self, world_id: WorldId) -> Option { + self.graph.types()[world_id] + .imports + .keys() + .find_map(|name| name.strip_prefix("wasi:cli/environment@").map(String::from)) + } + + /// Returns true when environment isolation should be applied. + /// + /// Environment isolation is applied if there is at least one dependency. + // Note: In principle this is overly conservative since we could check + // whether the main component or any dependency imports `wasi:cli/environment`, + // but in practice almost all components will import `wasi:cli/environment`, + // so we go with this simpler heuristic for now. + fn should_apply_env_isolation(&self, prepared: &IndexMap) -> bool { + prepared.values().len() > 0 + } + + /// Apply environment variable isolation. + /// + /// Generates an isolator component shared across all targets, plus a small + /// wrapper component per target that bundles flat functions back into a + /// `wasi:cli/environment` instance. + fn apply_env_isolation( + &mut self, + component_id: &str, + main_instance: NodeId, + main_world: WorldId, + prepared: &IndexMap, + ) -> anyhow::Result<()> { + use spin_env_isolator::isolator::{generate_isolator, IsolationTarget}; + use spin_env_isolator::wrapper::build_env_wrapper_component; + + // Collect isolation targets (components that import wasi:cli/environment). + // Each target tracks the specific wasi:cli/environment version it imports, + // since different components may import different versions. + let mut targets = Vec::new(); + let mut target_instances: Vec<(String, NodeId, String)> = Vec::new(); + + // Track the highest WASI env version seen; used for the isolator's import. + let mut max_wasi_env_version: Option = None; + + // Main component + if let Some(version) = self.detect_wasi_env_version(main_world) { + let prefix = spin_env_isolator::compute_prefix(component_id); + targets.push(IsolationTarget { + name: component_id.to_string(), + prefix, + }); + target_instances.push((component_id.to_string(), main_instance, version.clone())); + max_wasi_env_version = Some(version); + } + + // Dependencies importing wasi:cli/environment (deduplicated by manifest name) + let mut seen_deps = std::collections::HashSet::new(); + for (_, dep_info) in prepared { + if seen_deps.insert(&dep_info.manifest_name) { + let dep_name = dep_info.manifest_name.to_string(); + if let Some(version) = self.detect_wasi_env_version(dep_info.world_id) { + let prefix = spin_env_isolator::compute_prefix(&dep_name); + targets.push(IsolationTarget { + name: dep_name.clone(), + prefix, + }); + target_instances.push((dep_name, dep_info.instantiation_id, version.clone())); + if is_higher_version(&version, max_wasi_env_version.as_deref()) { + max_wasi_env_version = Some(version); + } + } + } + } + + if targets.is_empty() { + return Ok(()); + } + let imported_wasi_env_version = max_wasi_env_version.unwrap(); + + let isolator_bytes = generate_isolator(&targets, &imported_wasi_env_version)?; + let (_, isolator_instance) = self.register_package("env-isolator", None, isolator_bytes)?; + + // For each target, create a wrapper and wire isolator → wrapper → target. + // Each wrapper uses the target's own wasi:cli/environment version. + for (target_name, target_instance, target_wasi_env_version) in &target_instances { + let wrapper_bytes = build_env_wrapper_component(target_name, target_wasi_env_version)?; + let wrapper_pkg_name = format!("env-wrapper-{target_name}"); + let (_, wrapper_instance) = + self.register_package(&wrapper_pkg_name, None, wrapper_bytes)?; + + // Wire isolator → wrapper (filtered get-environment) + let export_name = format!("environment-{target_name}-get-environment"); + let node = self + .graph + .alias_instance_export(isolator_instance, &export_name)?; + self.graph + .set_instantiation_argument(wrapper_instance, &export_name, node)?; + + // Wire wrapper → target (wasi:cli/environment instance) + let env_import = format!("wasi:cli/environment@{target_wasi_env_version}"); + let node = self + .graph + .alias_instance_export(wrapper_instance, &env_import)?; + self.graph + .set_instantiation_argument(*target_instance, &env_import, node)?; + } + + Ok(()) + } } #[derive(Clone)] @@ -482,6 +597,18 @@ struct DependencyInfo { export_name: Option, } +/// Returns true if `candidate` is a higher semver version than `current` +/// (or if `current` is `None`). +fn is_higher_version(candidate: &str, current: Option<&str>) -> bool { + let Some(current) = current else { + return true; + }; + match (Version::parse(candidate), Version::parse(current)) { + (Ok(c), Ok(cur)) => c > cur, + _ => candidate > current, + } +} + fn apply_deny_all_adapter( dependency_name: &str, dependency_source: &[u8], @@ -639,4 +766,19 @@ mod test { } } } + + #[test] + fn test_is_higher_version() { + // Basic comparisons + assert!(is_higher_version("0.2.10", Some("0.2.9"))); + assert!(!is_higher_version("0.2.9", Some("0.2.10"))); + assert!(!is_higher_version("0.2.9", Some("0.2.9"))); + + // Major/minor differences + assert!(is_higher_version("1.0.0", Some("0.9.9"))); + assert!(is_higher_version("0.3.0", Some("0.2.99"))); + + // None means no current version, so any candidate is higher + assert!(is_higher_version("0.2.6", None)); + } } diff --git a/crates/env-isolator/Cargo.toml b/crates/env-isolator/Cargo.toml new file mode 100644 index 0000000000..8a6a73a5ad --- /dev/null +++ b/crates/env-isolator/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "spin-env-isolator" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow = { workspace = true } +wasm-encoder = { workspace = true, features = ["component-model"] } +wasmparser = { workspace = true, features = ["validate", "component-model"] } + +[dev-dependencies] +wasmtime = { workspace = true } + +[lints] +workspace = true diff --git a/crates/env-isolator/src/core_module.rs b/crates/env-isolator/src/core_module.rs new file mode 100644 index 0000000000..689d0b2404 --- /dev/null +++ b/crates/env-isolator/src/core_module.rs @@ -0,0 +1,1091 @@ +//! Core module generation for environment variable prefix filtering. +//! +//! # Architecture +//! +//! This module generates two core Wasm modules that work together: +//! +//! 1. **Memory module** ([`build_memory_module`]): Owns the linear memory and provides +//! a bump allocator (`realloc`) plus a `reset` function. The bump allocator is +//! intentionally simple — it never frees individual allocations. Instead, the +//! *entire* heap is recycled by calling `reset`, which moves the bump pointer back +//! to the start of the heap. Memory growth (`memory.grow`) is handled automatically +//! when an allocation would exceed the current memory size. +//! +//! 2. **Filter module** ([`generate_env_filter_module`]): Imports memory, `realloc`, +//! `reset`, and the lowered host `get-environment`. Each exported function calls `reset` at +//! the top so that every call starts with a clean heap — safe because each call is +//! independent and the component model guarantees that memory contents aren't +//! observable between calls. +//! +//! # Canonical ABI layout +//! +//! The canonical ABI representation of `get-environment: func() -> list>` +//! when lowered to a core function uses: +//! - A return pointer parameter (i32) pointing to where the result is stored +//! - The result at the return pointer is: (i32, i32) = (list_ptr, list_len) +//! - Each list element is a tuple of two strings: (ptr, len, ptr, len) = 4 × i32 = 16 bytes + +use wasm_encoder::{ + BlockType, CodeSection, ConstExpr, DataCountSection, DataSection, DataSegment, DataSegmentMode, + EntityType, ExportKind, ExportSection, Function, FunctionSection, GlobalSection, GlobalType, + ImportSection, MemArg, MemorySection, MemoryType, Module, TypeSection, ValType, +}; + +// --- Filter module indices --- + +// Type indices +const FILTER_TY_LOWERED: u32 = 0; // (i32) -> () +const FILTER_TY_REALLOC: u32 = 1; // (i32, i32, i32, i32) -> i32 +const FILTER_TY_LIFTED: u32 = 2; // () -> (i32) +const FILTER_TY_RESET: u32 = 3; // () -> () + +// Imported function indices (imports precede defined functions in Wasm) +const FILTER_FN_GET_ENVIRONMENT: u32 = 0; +const FILTER_FN_REALLOC: u32 = 1; +const FILTER_FN_RESET: u32 = 2; + +// Defined function indices +const FILTER_FN_GET_ENV_BASE: u32 = 3; + +// --- Memory module indices --- + +// Type indices +const MEM_TY_REALLOC: u32 = 0; +const MEM_TY_RESET: u32 = 1; + +// Function indices +const MEM_FN_REALLOC: u32 = 0; +const MEM_FN_RESET: u32 = 1; + +// Global indices +const MEM_GLOBAL_BUMP_PTR: u32 = 0; + +/// Generate a filter core module that imports memory instead of defining its own. +/// +/// Imports are: +/// - `"memory"`: linear memory +/// - `"get-environment"`: lowered (i32) -> () [Return Pointer] +/// - `"realloc"`: (i32, i32, i32, i32) -> i32 +/// - `"reset"`: () -> () — resets the bump allocator (called at the start of each export) +pub fn generate_env_filter_module(prefixes: &[&str]) -> Vec { + let mut module = Module::new(); + + // === Type section === + // Types are assigned indices sequentially; see FILTER_TY_* constants. + let mut types = TypeSection::new(); + types.ty().function(vec![ValType::I32], vec![]); + types + .ty() + .function(vec![ValType::I32; 4], vec![ValType::I32]); + types.ty().function(vec![], vec![ValType::I32]); + types.ty().function(vec![], vec![]); + module.section(&types); + + // === Import section === + let mut imports = ImportSection::new(); + imports.import( + "host", + "memory", + EntityType::Memory(MemoryType { + minimum: 1, + maximum: None, + memory64: false, + shared: false, + page_size_log2: None, + }), + ); + imports.import( + "host", + "get-environment", + EntityType::Function(FILTER_TY_LOWERED), + ); + imports.import("host", "realloc", EntityType::Function(FILTER_TY_REALLOC)); + imports.import("host", "reset", EntityType::Function(FILTER_TY_RESET)); + module.section(&imports); + + // === Function section === + let num_prefix_funcs = prefixes.len() as u32; + let mut functions = FunctionSection::new(); + for _ in 0..num_prefix_funcs { + functions.function(FILTER_TY_LIFTED); + } + module.section(&functions); + + // === Export section === + let mut exports = ExportSection::new(); + for i in 0..num_prefix_funcs { + exports.export( + &format!("get-environment-{i}"), + ExportKind::Func, + FILTER_FN_GET_ENV_BASE + i, + ); + } + module.section(&exports); + + module.section(&DataCountSection { + count: prefixes.len() as u32, + }); + + // === Code section === + let mut codes = CodeSection::new(); + + // Filtered get-environment for each prefix + for (i, prefix) in prefixes.iter().enumerate() { + let prefix_offset = compute_prefix_offset(prefixes, i); + let prefix_len = prefix.len() as i32; + codes.function(&build_filter_env_function(prefix_offset, prefix_len)); + } + + module.section(&codes); + + // === Data section === + let mut data = DataSection::new(); + let mut offset = 0i32; + for prefix in prefixes { + data.segment(DataSegment { + mode: DataSegmentMode::Active { + memory_index: 0, + offset: &ConstExpr::i32_const(offset), + }, + data: prefix.as_bytes().to_vec(), + }); + offset += prefix.len() as i32; + } + module.section(&data); + + module.finish() +} + +/// Build a minimal core module that provides memory, a bump-allocator `realloc`, +/// and a `reset` function. +/// +/// The `heap_start` parameter is the byte offset into the module's linear memory at which +/// the dynamic heap begins. Callers should set this to the end of all static data segments +/// (and any required alignment padding) so that the bump-allocator does not overlap or +/// overwrite embedded static data. +/// +/// # Exports +/// +/// - `memory`: the linear memory (1 page minimum, growable) +/// - `realloc(old_ptr, old_size, align, new_size) -> ptr`: bump allocator that grows +/// memory automatically when needed +/// - `reset()`: resets the bump pointer back to `heap_start`, effectively freeing all +/// dynamic allocations. Safe because each component-model call is independent. +pub fn build_memory_module(heap_start: u32) -> Module { + let mut module = Module::new(); + + // Type section — indices must match MEM_TY_* constants + let mut types = TypeSection::new(); + types + .ty() + .function(vec![ValType::I32; 4], vec![ValType::I32]); + types.ty().function(vec![], vec![]); + module.section(&types); + + // Function section + let mut functions = FunctionSection::new(); + functions.function(MEM_TY_REALLOC); + functions.function(MEM_TY_RESET); + module.section(&functions); + + // Memory section + let mut memories = MemorySection::new(); + memories.memory(MemoryType { + minimum: 1, + maximum: None, + memory64: false, + shared: false, + page_size_log2: None, + }); + module.section(&memories); + + // Global section — bump pointer + let mut globals = GlobalSection::new(); + globals.global( + GlobalType { + val_type: ValType::I32, + mutable: true, + shared: false, + }, + &ConstExpr::i32_const(heap_start as i32), + ); + module.section(&globals); + + // Export section + let mut exports = ExportSection::new(); + exports.export("memory", ExportKind::Memory, 0); + exports.export("realloc", ExportKind::Func, MEM_FN_REALLOC); + exports.export("reset", ExportKind::Func, MEM_FN_RESET); + module.section(&exports); + + // Code section + let mut codes = CodeSection::new(); + + // realloc — bump allocator with memory growth + // + // Pseudocode: + // aligned = (bump_ptr + align - 1) & ~(align - 1) + // new_bump = aligned + new_size + // if new_bump > memory.size * 65536: + // pages_needed = ceil((new_bump - mem_bytes) / 65536) + // if memory.grow(pages_needed) == -1: unreachable + // bump_ptr = new_bump + // return aligned + { + // params: 0=old_ptr, 1=old_size, 2=align, 3=new_size + // locals: 4=aligned_ptr, 5=new_bump + let mut f = Function::new(vec![(2, ValType::I32)]); + + f.instructions() + // aligned = (bump_ptr + align - 1) & ~(align - 1) + .global_get(MEM_GLOBAL_BUMP_PTR) + .local_get(2) + .i32_const(1) + .i32_sub() + .i32_add() + .i32_const(0) + .local_get(2) + .i32_sub() + .i32_and() + .local_set(4) + // new_bump = aligned + new_size + .local_get(4) + .local_get(3) + .i32_add() + .local_set(5) + // if new_bump > memory.size * 65536, grow + .local_get(5) + .memory_size(0) + .i32_const(65536) + .i32_mul() + .i32_gt_u() + .if_(BlockType::Empty) + // pages_needed = ceil((new_bump - mem_bytes) / 65536) + .local_get(5) + .memory_size(0) + .i32_const(65536) + .i32_mul() + .i32_sub() + .i32_const(65535) + .i32_add() + .i32_const(65536) + .i32_div_u() + .memory_grow(0) + // memory.grow returns -1 on failure + .i32_const(-1) + .i32_eq() + .if_(BlockType::Empty) + .unreachable() + .end() + .end() + // bump_ptr = new_bump + .local_get(5) + .global_set(MEM_GLOBAL_BUMP_PTR) + // return aligned + .local_get(4) + .end(); + + codes.function(&f); + } + + // reset — set bump pointer back to heap_start + { + let mut f = Function::new(vec![]); + f.instructions() + .i32_const(heap_start as i32) + .global_set(MEM_GLOBAL_BUMP_PTR) + .end(); + codes.function(&f); + } + + module.section(&codes); + + module +} + +/// Compute the memory offset of the i-th prefix string. +fn compute_prefix_offset(prefixes: &[&str], index: usize) -> i32 { + prefixes[..index].iter().map(|p| p.len() as i32).sum() +} + +/// Build the filter function for a single component's get-environment. +/// +/// This function: +/// 1. Calls `reset` to reclaim all prior allocations +/// 2. Calls the host's get-environment (lowered, return-pointer) to get all env vars +/// 3. Iterates through them, checking if each key starts with the prefix +/// 4. Builds a new list with matching entries (prefix stripped from key) +/// +/// The function signature is `() -> (i32)` (spilled return): +/// the function allocates a result area, writes `(list_ptr, list_len)` to it, +/// and returns a pointer to that area. +/// +fn build_filter_env_function(prefix_offset: i32, prefix_len: i32) -> Function { + // No params, locals start at index 0 + const TEMP_PTR: u32 = 0; + const HOST_LIST_PTR: u32 = 1; + const HOST_LIST_LEN: u32 = 2; + const OUTPUT_LIST: u32 = 3; + const OUTPUT_COUNT: u32 = 4; + const ELEMENT_PTR: u32 = 5; + const KEY_PTR: u32 = 6; + const KEY_LEN: u32 = 7; + const VAL_PTR: u32 = 8; + const VAL_LEN: u32 = 9; + const MATCH_FLAG: u32 = 10; + const LOOP_I: u32 = 11; + const LOOP_J: u32 = 12; + const DEST_BASE: u32 = 13; + + let locals = vec![(14, ValType::I32)]; + let mut f = Function::new(locals); + + let mut insn = f.instructions(); + + // Reset bump allocator — safe because each call is independent + insn.call(FILTER_FN_RESET); + + // Allocate 8 bytes for temp return area to call host + insn.i32_const(0) + .i32_const(0) + .i32_const(4) + .i32_const(8) + .call(FILTER_FN_REALLOC) + .local_tee(TEMP_PTR) + .call(FILTER_FN_GET_ENVIRONMENT); + + // Read host list pointer and length from temp area + insn.local_get(TEMP_PTR) + .i32_load(MemArg { + offset: 0, + align: 2, + memory_index: 0, + }) + .local_set(HOST_LIST_PTR); + + insn.local_get(TEMP_PTR) + .i32_load(MemArg { + offset: 4, + align: 2, + memory_index: 0, + }) + .local_set(HOST_LIST_LEN); + + // Allocate output list (worst case: host_list_len entries x 16 bytes each) + insn.i32_const(0) + .i32_const(0) + .i32_const(4) + .local_get(HOST_LIST_LEN) + .i32_const(16) + .i32_mul() + .call(FILTER_FN_REALLOC) + .local_set(OUTPUT_LIST); + + // Initialize counters + insn.i32_const(0).local_set(OUTPUT_COUNT); + insn.i32_const(0).local_set(LOOP_I); + + // Loop over all env vars + insn.block(BlockType::Empty); + insn.loop_(BlockType::Empty); + + // if i >= host_list_len, break + insn.local_get(LOOP_I) + .local_get(HOST_LIST_LEN) + .i32_ge_u() + .br_if(1); + + // element_ptr = host_list_ptr + i * 16 + insn.local_get(HOST_LIST_PTR) + .local_get(LOOP_I) + .i32_const(16) + .i32_mul() + .i32_add() + .local_set(ELEMENT_PTR); + + // Read key_ptr from element + insn.local_get(ELEMENT_PTR) + .i32_load(MemArg { + offset: 0, + align: 2, + memory_index: 0, + }) + .local_set(KEY_PTR); + + // Read key_len from element + insn.local_get(ELEMENT_PTR) + .i32_load(MemArg { + offset: 4, + align: 2, + memory_index: 0, + }) + .local_set(KEY_LEN); + + // Read val_ptr from element + insn.local_get(ELEMENT_PTR) + .i32_load(MemArg { + offset: 8, + align: 2, + memory_index: 0, + }) + .local_set(VAL_PTR); + + // Read val_len from element + insn.local_get(ELEMENT_PTR) + .i32_load(MemArg { + offset: 12, + align: 2, + memory_index: 0, + }) + .local_set(VAL_LEN); + + // Check if key_len >= prefix_len + insn.local_get(KEY_LEN).i32_const(prefix_len).i32_lt_u(); + + // If key is shorter than prefix, skip + insn.if_(BlockType::Empty); + { + insn.local_get(LOOP_I) + .i32_const(1) + .i32_add() + .local_set(LOOP_I); + insn.br(1); + } + insn.end(); + + // Compare prefix bytes + insn.i32_const(1).local_set(MATCH_FLAG); + insn.i32_const(0).local_set(LOOP_J); + + insn.block(BlockType::Empty); + insn.loop_(BlockType::Empty); + + // if j >= prefix_len, break + insn.local_get(LOOP_J) + .i32_const(prefix_len) + .i32_ge_u() + .br_if(1); + + // compare key[j] vs prefix[j] + insn.local_get(KEY_PTR) + .local_get(LOOP_J) + .i32_add() + .i32_load8_u(MemArg { + offset: 0, + align: 0, + memory_index: 0, + }); + + insn.i32_const(prefix_offset) + .local_get(LOOP_J) + .i32_add() + .i32_load8_u(MemArg { + offset: 0, + align: 0, + memory_index: 0, + }); + + insn.i32_ne(); + + insn.if_(BlockType::Empty); + { + insn.i32_const(0).local_set(MATCH_FLAG); + insn.br(2); + } + insn.end(); + + // j++ + insn.local_get(LOOP_J) + .i32_const(1) + .i32_add() + .local_set(LOOP_J); + + insn.br(0); + insn.end(); // loop + insn.end(); // block + + // If no match, skip + insn.local_get(MATCH_FLAG).i32_eqz(); + + insn.if_(BlockType::Empty); + { + insn.local_get(LOOP_I) + .i32_const(1) + .i32_add() + .local_set(LOOP_I); + insn.br(1); + } + insn.end(); + + // Matched — write to output list + // dest = output_list + output_count * 16 + insn.local_get(OUTPUT_LIST) + .local_get(OUTPUT_COUNT) + .i32_const(16) + .i32_mul() + .i32_add() + .local_set(DEST_BASE); + + insn.local_get(DEST_BASE); + insn.local_get(KEY_PTR).i32_const(prefix_len).i32_add(); + insn.i32_store(MemArg { + offset: 0, + align: 2, + memory_index: 0, + }); + + insn.local_get(DEST_BASE); + insn.local_get(KEY_LEN).i32_const(prefix_len).i32_sub(); + insn.i32_store(MemArg { + offset: 4, + align: 2, + memory_index: 0, + }); + + insn.local_get(DEST_BASE); + insn.local_get(VAL_PTR); + insn.i32_store(MemArg { + offset: 8, + align: 2, + memory_index: 0, + }); + + insn.local_get(DEST_BASE); + insn.local_get(VAL_LEN); + insn.i32_store(MemArg { + offset: 12, + align: 2, + memory_index: 0, + }); + + // output_count++ + insn.local_get(OUTPUT_COUNT) + .i32_const(1) + .i32_add() + .local_set(OUTPUT_COUNT); + + // i++ + insn.local_get(LOOP_I) + .i32_const(1) + .i32_add() + .local_set(LOOP_I); + + insn.br(0); + insn.end(); // loop + insn.end(); // block + + // Spilled return: allocate result area, write to it, return pointer. + insn.i32_const(0) + .i32_const(0) + .i32_const(4) + .i32_const(8) + .call(FILTER_FN_REALLOC) + .local_set(TEMP_PTR); + insn.local_get(TEMP_PTR) + .local_get(OUTPUT_LIST) + .i32_store(MemArg { + offset: 0, + align: 2, + memory_index: 0, + }); + insn.local_get(TEMP_PTR) + .local_get(OUTPUT_COUNT) + .i32_store(MemArg { + offset: 4, + align: 2, + memory_index: 0, + }); + insn.local_get(TEMP_PTR); + + insn.end(); + + f +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_validates() { + let module_bytes = generate_env_filter_module(&["main_", "sub_"]); + wasmparser::validate(&module_bytes).expect("generated module should be valid"); + } + + #[test] + fn test_generate_single_prefix() { + let module_bytes = generate_env_filter_module(&["app_"]); + wasmparser::validate(&module_bytes).expect("single prefix module should be valid"); + } + + #[test] + fn test_generate_many_prefixes() { + let owned_prefixes: Vec = (0..10).map(|i| format!("comp{i}_")).collect(); + let prefixes: Vec<&str> = owned_prefixes.iter().map(|s| s.as_str()).collect(); + let module_bytes = generate_env_filter_module(&prefixes); + wasmparser::validate(&module_bytes).expect("many-prefix module should be valid"); + } + + // --- wasmtime runtime tests for the memory module --- + + /// Helper: instantiate the memory module in wasmtime and return (store, instance, memory). + fn instantiate_memory_module( + heap_start: u32, + ) -> (wasmtime::Store<()>, wasmtime::Instance, wasmtime::Memory) { + let module = build_memory_module(heap_start); + let bytes = module.finish(); + wasmparser::validate(&bytes).expect("memory module should be valid"); + + let engine = wasmtime::Engine::default(); + let wasm_module = + wasmtime::Module::new(&engine, &bytes).expect("failed to compile memory module"); + let mut store = wasmtime::Store::new(&engine, ()); + let instance = wasmtime::Instance::new(&mut store, &wasm_module, &[]) + .expect("failed to instantiate memory module"); + let memory = instance + .get_memory(&mut store, "memory") + .expect("missing memory export"); + (store, instance, memory) + } + + /// Call the exported `realloc` function. + fn call_realloc( + store: &mut wasmtime::Store<()>, + instance: &wasmtime::Instance, + old_ptr: i32, + old_size: i32, + align: i32, + new_size: i32, + ) -> i32 { + let realloc = instance + .get_typed_func::<(i32, i32, i32, i32), i32>(&mut *store, "realloc") + .expect("missing realloc export"); + realloc + .call(&mut *store, (old_ptr, old_size, align, new_size)) + .expect("realloc call failed") + } + + /// Call the exported `reset` function. + fn call_reset(store: &mut wasmtime::Store<()>, instance: &wasmtime::Instance) { + let reset = instance + .get_typed_func::<(), ()>(&mut *store, "reset") + .expect("missing reset export"); + reset.call(&mut *store, ()).expect("reset call failed"); + } + + #[test] + fn realloc_returns_aligned_pointers() { + let (mut store, instance, _memory) = instantiate_memory_module(0); + + // Allocate with alignment 4 + let ptr1 = call_realloc(&mut store, &instance, 0, 0, 4, 10); + assert_eq!(ptr1 % 4, 0, "pointer should be 4-byte aligned"); + + // Allocate with alignment 8 + let ptr2 = call_realloc(&mut store, &instance, 0, 0, 8, 20); + assert_eq!(ptr2 % 8, 0, "pointer should be 8-byte aligned"); + assert!( + ptr2 >= ptr1 + 10, + "second allocation should not overlap first" + ); + } + + #[test] + fn realloc_respects_heap_start() { + let heap_start = 128u32; + let (mut store, instance, _memory) = instantiate_memory_module(heap_start); + + let ptr = call_realloc(&mut store, &instance, 0, 0, 1, 8); + assert!( + ptr >= heap_start as i32, + "allocation should be at or after heap_start ({ptr} < {heap_start})" + ); + } + + #[test] + fn reset_reclaims_memory() { + let heap_start = 64u32; + let (mut store, instance, _memory) = instantiate_memory_module(heap_start); + + let ptr1 = call_realloc(&mut store, &instance, 0, 0, 4, 100); + let ptr2 = call_realloc(&mut store, &instance, 0, 0, 4, 100); + assert!(ptr2 > ptr1, "second alloc should be after first"); + + // After reset, the next allocation should start back at heap_start + call_reset(&mut store, &instance); + let ptr3 = call_realloc(&mut store, &instance, 0, 0, 4, 100); + assert_eq!( + ptr3, ptr1, + "after reset, allocation should reuse the same address" + ); + } + + #[test] + fn realloc_grows_memory_when_needed() { + // Start with heap_start near the end of the first page (64KiB = 65536) + let heap_start = 65000u32; + let (mut store, instance, memory) = instantiate_memory_module(heap_start); + + let initial_pages = memory.size(&store); + assert_eq!(initial_pages, 1); + + // Allocate more than the remaining space in the first page + let ptr = call_realloc(&mut store, &instance, 0, 0, 4, 2000); + assert!(ptr >= heap_start as i32); + + let new_pages = memory.size(&store); + assert!( + new_pages > initial_pages, + "memory should have grown (was {initial_pages}, now {new_pages})" + ); + } + + #[test] + fn realloc_handles_large_allocation() { + let (mut store, instance, memory) = instantiate_memory_module(0); + + // Allocate 3 full pages worth of data (196608 bytes) + let size = 3 * 65536; + let ptr = call_realloc(&mut store, &instance, 0, 0, 1, size); + assert_eq!(ptr, 0); + + let new_pages = memory.size(&store); + // Started with 1 page (65536 bytes), need 196608 total → grow by 2 → 3 pages + assert!( + new_pages >= 3, + "memory should be at least 3 pages, got {new_pages}" + ); + } + + #[test] + fn repeated_calls_with_reset_dont_grow_unboundedly() { + let (mut store, instance, memory) = instantiate_memory_module(0); + + // Simulate many calls, each allocating ~1000 bytes then resetting + for _ in 0..1000 { + call_realloc(&mut store, &instance, 0, 0, 4, 1000); + call_reset(&mut store, &instance); + } + + // Memory should not have grown beyond 1 page since we reset each time + let pages = memory.size(&store); + assert_eq!( + pages, 1, + "memory should still be 1 page after repeated alloc+reset cycles" + ); + } + + #[test] + fn realloc_data_is_writable_and_readable() { + let (mut store, instance, memory) = instantiate_memory_module(0); + + let ptr = call_realloc(&mut store, &instance, 0, 0, 1, 4) as usize; + // Write and read back data + memory.data_mut(&mut store)[ptr..ptr + 4].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); + let read = &memory.data(&store)[ptr..ptr + 4]; + assert_eq!(read, &[0xDE, 0xAD, 0xBE, 0xEF]); + } + + // --- Integrated filter + memory module tests --- + // + // These tests link the filter module with the memory module and mock host + // functions to verify end-to-end filtering behavior at the core Wasm level. + + /// Environment variables to inject via the mock host get-environment. + /// Stored as a Vec to be shared with the host callback via store data. + struct FilterTestState { + env_vars: Vec<(String, String)>, + } + + /// Write a string into linear memory at `offset`, returning the number of bytes written. + fn write_str( + memory: &wasmtime::Memory, + store: &mut impl wasmtime::AsContextMut, + offset: usize, + s: &str, + ) -> usize { + let bytes = s.as_bytes(); + memory.data_mut(store)[offset..offset + bytes.len()].copy_from_slice(bytes); + bytes.len() + } + + /// Read (list_ptr, list_len) from the return area, then read each + /// (key_ptr, key_len, val_ptr, val_len) tuple from the list. + fn read_env_result( + memory: &wasmtime::Memory, + store: &wasmtime::Store, + result_ptr: i32, + ) -> Vec<(String, String)> { + let data = memory.data(store); + let rp = result_ptr as usize; + let list_ptr = u32::from_le_bytes(data[rp..rp + 4].try_into().unwrap()) as usize; + let list_len = u32::from_le_bytes(data[rp + 4..rp + 8].try_into().unwrap()) as usize; + + let mut result = Vec::with_capacity(list_len); + for i in 0..list_len { + let base = list_ptr + i * 16; + let kp = u32::from_le_bytes(data[base..base + 4].try_into().unwrap()) as usize; + let kl = u32::from_le_bytes(data[base + 4..base + 8].try_into().unwrap()) as usize; + let vp = u32::from_le_bytes(data[base + 8..base + 12].try_into().unwrap()) as usize; + let vl = u32::from_le_bytes(data[base + 12..base + 16].try_into().unwrap()) as usize; + let key = std::str::from_utf8(&data[kp..kp + kl]).unwrap().to_string(); + let val = std::str::from_utf8(&data[vp..vp + vl]).unwrap().to_string(); + result.push((key, val)); + } + result + } + + /// Instantiate the filter module linked with the memory module and a mock + /// `get-environment` host function that writes `env_vars` into linear memory. + fn instantiate_filter_module( + prefixes: &[&str], + env_vars: Vec<(String, String)>, + ) -> ( + wasmtime::Store, + wasmtime::Instance, + wasmtime::Memory, + ) { + let total_prefix_bytes: usize = prefixes.iter().map(|p| p.len()).sum(); + + // Build both modules + let mem_module = build_memory_module(total_prefix_bytes as u32); + let mem_bytes = mem_module.finish(); + let filter_bytes = generate_env_filter_module(prefixes); + + let engine = wasmtime::Engine::default(); + let mem_wasm = wasmtime::Module::new(&engine, &mem_bytes).unwrap(); + let filter_wasm = wasmtime::Module::new(&engine, &filter_bytes).unwrap(); + + let mut store = wasmtime::Store::new(&engine, FilterTestState { env_vars }); + + // Instantiate memory module (no imports) + let mem_instance = wasmtime::Instance::new(&mut store, &mem_wasm, &[]).unwrap(); + let memory = mem_instance.get_memory(&mut store, "memory").unwrap(); + let realloc_fn = mem_instance.get_func(&mut store, "realloc").unwrap(); + let reset_fn = mem_instance.get_func(&mut store, "reset").unwrap(); + + // Create mock host functions for get-environment, get-arguments, initial-cwd. + // + // get-environment writes the env vars into linear memory using realloc + // for allocations, then writes (list_ptr, list_len) at the return pointer. + let get_env = wasmtime::Func::wrap( + &mut store, + move |mut caller: wasmtime::Caller<'_, FilterTestState>, ret_ptr: i32| { + let env_vars = caller.data().env_vars.clone(); + let n = env_vars.len(); + + // Allocate list: n entries × 16 bytes + let list_size = (n * 16) as i32; + let mut results = [wasmtime::Val::I32(0)]; + realloc_fn + .call( + &mut caller, + &[ + wasmtime::Val::I32(0), + wasmtime::Val::I32(0), + wasmtime::Val::I32(4), + wasmtime::Val::I32(list_size), + ], + &mut results, + ) + .unwrap(); + let list_ptr = results[0].unwrap_i32(); + + // For each env var, allocate and write key and value strings + for (i, (key, val)) in env_vars.iter().enumerate() { + // Allocate key + let mut kr = [wasmtime::Val::I32(0)]; + realloc_fn + .call( + &mut caller, + &[ + wasmtime::Val::I32(0), + wasmtime::Val::I32(0), + wasmtime::Val::I32(1), + wasmtime::Val::I32(key.len() as i32), + ], + &mut kr, + ) + .unwrap(); + let key_ptr = kr[0].unwrap_i32(); + write_str(&memory, &mut caller, key_ptr as usize, key); + + // Allocate val + let mut vr = [wasmtime::Val::I32(0)]; + realloc_fn + .call( + &mut caller, + &[ + wasmtime::Val::I32(0), + wasmtime::Val::I32(0), + wasmtime::Val::I32(1), + wasmtime::Val::I32(val.len() as i32), + ], + &mut vr, + ) + .unwrap(); + let val_ptr = vr[0].unwrap_i32(); + write_str(&memory, &mut caller, val_ptr as usize, val); + + // Write tuple into list + let base = (list_ptr + (i as i32) * 16) as usize; + let data = memory.data_mut(&mut caller); + data[base..base + 4].copy_from_slice(&(key_ptr as u32).to_le_bytes()); + data[base + 4..base + 8].copy_from_slice(&(key.len() as u32).to_le_bytes()); + data[base + 8..base + 12].copy_from_slice(&(val_ptr as u32).to_le_bytes()); + data[base + 12..base + 16].copy_from_slice(&(val.len() as u32).to_le_bytes()); + } + + // Write (list_ptr, list_len) at ret_ptr + let rp = ret_ptr as usize; + let data = memory.data_mut(&mut caller); + data[rp..rp + 4].copy_from_slice(&(list_ptr as u32).to_le_bytes()); + data[rp + 4..rp + 8].copy_from_slice(&(n as u32).to_le_bytes()); + }, + ); + + // Instantiate filter module with imports + let filter_instance = wasmtime::Instance::new( + &mut store, + &filter_wasm, + &[ + memory.into(), + get_env.into(), + realloc_fn.into(), + reset_fn.into(), + ], + ) + .unwrap(); + + (store, filter_instance, memory) + } + + #[test] + fn filter_basic_prefix_matching() { + let env_vars = vec![ + ("APP_FOO".into(), "val1".into()), + ("APP_BAR".into(), "val2".into()), + ("OTHER_X".into(), "val3".into()), + ]; + let (mut store, instance, memory) = instantiate_filter_module(&["APP_"], env_vars); + + let get_env_0 = instance + .get_typed_func::<(), i32>(&mut store, "get-environment-0") + .unwrap(); + let result_ptr = get_env_0.call(&mut store, ()).unwrap(); + let vars = read_env_result(&memory, &store, result_ptr); + + // Should contain FOO and BAR (prefix "APP_" stripped) + assert_eq!(vars.len(), 2); + assert!(vars.contains(&("FOO".to_string(), "val1".to_string()))); + assert!(vars.contains(&("BAR".to_string(), "val2".to_string()))); + } + + #[test] + fn filter_no_matches_returns_empty() { + let env_vars = vec![ + ("OTHER_X".into(), "val1".into()), + ("THING_Y".into(), "val2".into()), + ]; + let (mut store, instance, memory) = instantiate_filter_module(&["APP_"], env_vars); + + let get_env_0 = instance + .get_typed_func::<(), i32>(&mut store, "get-environment-0") + .unwrap(); + let result_ptr = get_env_0.call(&mut store, ()).unwrap(); + let vars = read_env_result(&memory, &store, result_ptr); + + assert!(vars.is_empty()); + } + + #[test] + fn filter_multiple_prefixes() { + let env_vars = vec![ + ("MAIN_A".into(), "1".into()), + ("MAIN_B".into(), "2".into()), + ("SUB_C".into(), "3".into()), + ("SUB_D".into(), "4".into()), + ("OTHER".into(), "5".into()), + ]; + let (mut store, instance, memory) = instantiate_filter_module(&["MAIN_", "SUB_"], env_vars); + + // Check MAIN_ filter + let get_env_0 = instance + .get_typed_func::<(), i32>(&mut store, "get-environment-0") + .unwrap(); + let result_ptr = get_env_0.call(&mut store, ()).unwrap(); + let main_vars = read_env_result(&memory, &store, result_ptr); + assert_eq!(main_vars.len(), 2); + assert!(main_vars.contains(&("A".to_string(), "1".to_string()))); + assert!(main_vars.contains(&("B".to_string(), "2".to_string()))); + + // Check SUB_ filter + let get_env_1 = instance + .get_typed_func::<(), i32>(&mut store, "get-environment-1") + .unwrap(); + let result_ptr = get_env_1.call(&mut store, ()).unwrap(); + let sub_vars = read_env_result(&memory, &store, result_ptr); + assert_eq!(sub_vars.len(), 2); + assert!(sub_vars.contains(&("C".to_string(), "3".to_string()))); + assert!(sub_vars.contains(&("D".to_string(), "4".to_string()))); + } + + #[test] + fn filter_repeated_calls_dont_leak_memory() { + let env_vars = vec![("APP_X".into(), "value".into())]; + let (mut store, instance, memory) = instantiate_filter_module(&["APP_"], env_vars); + + let get_env_0 = instance + .get_typed_func::<(), i32>(&mut store, "get-environment-0") + .unwrap(); + + // Call many times — the reset at the top of each call prevents unbounded growth + for _ in 0..500 { + let result_ptr = get_env_0.call(&mut store, ()).unwrap(); + let vars = read_env_result(&memory, &store, result_ptr); + assert_eq!(vars.len(), 1); + assert_eq!(vars[0], ("X".to_string(), "value".to_string())); + } + + // Memory should not have grown beyond 1 page for this small dataset + let pages = memory.size(&store); + assert_eq!( + pages, 1, + "memory should still be 1 page after repeated calls" + ); + } + + #[test] + fn filter_exact_prefix_match_yields_empty_key() { + // A key that exactly equals the prefix should produce an empty key + let env_vars = vec![("PRE_".into(), "val".into())]; + let (mut store, instance, memory) = instantiate_filter_module(&["PRE_"], env_vars); + + let get_env_0 = instance + .get_typed_func::<(), i32>(&mut store, "get-environment-0") + .unwrap(); + let result_ptr = get_env_0.call(&mut store, ()).unwrap(); + let vars = read_env_result(&memory, &store, result_ptr); + + assert_eq!(vars.len(), 1); + assert_eq!(vars[0], ("".to_string(), "val".to_string())); + } + + #[test] + fn filter_key_shorter_than_prefix_is_skipped() { + let env_vars = vec![ + ("AB".into(), "short".into()), // shorter than prefix "ABCDE_" + ("ABCDE_X".into(), "match".into()), // matches + ]; + let (mut store, instance, memory) = instantiate_filter_module(&["ABCDE_"], env_vars); + + let get_env_0 = instance + .get_typed_func::<(), i32>(&mut store, "get-environment-0") + .unwrap(); + let result_ptr = get_env_0.call(&mut store, ()).unwrap(); + let vars = read_env_result(&memory, &store, result_ptr); + + assert_eq!(vars.len(), 1); + assert_eq!(vars[0], ("X".to_string(), "match".to_string())); + } +} diff --git a/crates/env-isolator/src/isolator.rs b/crates/env-isolator/src/isolator.rs new file mode 100644 index 0000000000..744259810d --- /dev/null +++ b/crates/env-isolator/src/isolator.rs @@ -0,0 +1,220 @@ +//! Isolator component generation. +//! +//! Constructs a WebAssembly component that: +//! - Imports `wasi:cli/environment@{version}` from the host +//! - Embeds a core filter module (from `core_module`) +//! - Exports per-component filtered `get-environment` functions + +use anyhow::{Context, Result}; +use wasm_encoder::{ + Alias, CanonicalOption, ComponentBuilder, ComponentExportKind, ComponentOuterAliasKind, + ComponentTypeRef, ComponentValType, ExportKind, InstanceType, ModuleArg, PrimitiveValType, +}; + +use crate::core_module::{build_memory_module, generate_env_filter_module}; + +/// Information about a component that needs environment isolation. +pub struct IsolationTarget { + /// Logical name for this component (e.g., "main", "dep"). + pub name: String, + /// Environment variable prefix to filter by. + pub prefix: String, +} + +/// Generate an isolator component that filters environment variables per-component. +/// +/// The generated component: +/// 1. Imports `wasi:cli/environment@{wasi_env_version}` +/// 2. Contains a core module that filters env vars by prefix +/// 3. Exports `environment-{name}-get-environment` for each target +pub fn generate_isolator(targets: &[IsolationTarget], wasi_env_version: &str) -> Result> { + anyhow::ensure!( + !targets.is_empty(), + "at least one isolation target required" + ); + + let prefixes: Vec<&str> = targets.iter().map(|t| t.prefix.as_str()).collect(); + let core_module_bytes = generate_env_filter_module(&prefixes); + + #[cfg(debug_assertions)] + wasmparser::validate(&core_module_bytes) + .context("generated filter core module is not valid Wasm")?; + + let mut builder = ComponentBuilder::default(); + + // --- Component-level types --- + + // Type 0: tuple + let (tuple_ss_type, enc) = builder.type_defined(None); + enc.tuple([ + ComponentValType::Primitive(PrimitiveValType::String), + ComponentValType::Primitive(PrimitiveValType::String), + ]); + + // Type 1: list> + let (list_tss_type, enc) = builder.type_defined(None); + enc.list(ComponentValType::Type(tuple_ss_type)); + + // Type 2: list + let (list_s_type, enc) = builder.type_defined(None); + enc.list(ComponentValType::Primitive(PrimitiveValType::String)); + + // Type 3: option + let (option_s_type, enc) = builder.type_defined(None); + enc.option(ComponentValType::Primitive(PrimitiveValType::String)); + + // Type 4: func get-environment() -> list> + let (get_env_func_type, mut enc) = builder.type_function(None); + enc.params::<[(&str, ComponentValType); 0], ComponentValType>([]) + .result(Some(ComponentValType::Type(list_tss_type))); + + // Type 5: func get-arguments() -> list + let (get_args_func_type, mut enc) = builder.type_function(None); + enc.params::<[(&str, ComponentValType); 0], ComponentValType>([]) + .result(Some(ComponentValType::Type(list_s_type))); + + // Type 6: func initial-cwd() -> option + let (get_cwd_func_type, mut enc) = builder.type_function(None); + enc.params::<[(&str, ComponentValType); 0], ComponentValType>([]) + .result(Some(ComponentValType::Type(option_s_type))); + + // Type 7: instance type for wasi:cli/environment + let mut env_instance_type = InstanceType::new(); + env_instance_type.alias(Alias::Outer { + kind: ComponentOuterAliasKind::Type, + count: 1, + index: get_env_func_type, + }); + env_instance_type.alias(Alias::Outer { + kind: ComponentOuterAliasKind::Type, + count: 1, + index: get_args_func_type, + }); + env_instance_type.alias(Alias::Outer { + kind: ComponentOuterAliasKind::Type, + count: 1, + index: get_cwd_func_type, + }); + env_instance_type.export("get-environment", ComponentTypeRef::Func(0)); + env_instance_type.export("get-arguments", ComponentTypeRef::Func(1)); + env_instance_type.export("initial-cwd", ComponentTypeRef::Func(2)); + let env_instance_type_idx = builder.type_instance(None, &env_instance_type); + + // --- Import wasi:cli/environment --- + let import_name = format!("wasi:cli/environment@{wasi_env_version}"); + let host_env_instance = builder.import( + &import_name, + ComponentTypeRef::Instance(env_instance_type_idx), + ); + + // --- Alias get-environment from imported instance --- + let host_get_env = builder.alias_export( + host_env_instance, + "get-environment", + ComponentExportKind::Func, + ); + + // --- Embed core modules --- + let total_prefix_bytes: usize = targets.iter().map(|t| t.prefix.len()).sum(); + let aux_module = build_memory_module(total_prefix_bytes as u32); + let aux_module_idx = builder.core_module(Some("aux"), &aux_module); + let filter_module_idx = builder.core_module_raw(Some("filter"), &core_module_bytes); + + // --- Instantiate aux module (provides memory + realloc) --- + let aux_instance = + builder.core_instantiate(Some("aux"), aux_module_idx, Vec::<(&str, ModuleArg)>::new()); + + // Alias memory, realloc, and reset from aux + let memory = builder.core_alias_export(None, aux_instance, "memory", ExportKind::Memory); + let realloc = builder.core_alias_export(None, aux_instance, "realloc", ExportKind::Func); + let reset = builder.core_alias_export(None, aux_instance, "reset", ExportKind::Func); + + // --- Lower host functions --- + let lowered_get_env = builder.lower_func( + None, + host_get_env, + [ + CanonicalOption::Memory(memory), + CanonicalOption::Realloc(realloc), + ], + ); + + // --- Build import instance for filter module --- + let host_for_filter = builder.core_instantiate_exports( + Some("host-for-filter"), + vec![ + ("memory", ExportKind::Memory, memory), + ("get-environment", ExportKind::Func, lowered_get_env), + ("realloc", ExportKind::Func, realloc), + ("reset", ExportKind::Func, reset), + ], + ); + + // --- Instantiate filter module --- + let filter_instance = builder.core_instantiate( + Some("filter"), + filter_module_idx, + vec![("host", ModuleArg::Instance(host_for_filter))], + ); + + // --- For each target, lift the filtered get-environment and export --- + for (i, target) in targets.iter().enumerate() { + let filtered_get_env = builder.core_alias_export( + None, + filter_instance, + &format!("get-environment-{i}"), + ExportKind::Func, + ); + + let lifted_get_env = builder.lift_func( + None, + filtered_get_env, + get_env_func_type, + [ + CanonicalOption::Memory(memory), + CanonicalOption::Realloc(realloc), + ], + ); + + builder.export( + &format!("environment-{}-get-environment", target.name), + ComponentExportKind::Func, + lifted_get_env, + None, + ); + } + + let bytes = builder.finish(); + + #[cfg(debug_assertions)] + wasmparser::validate(&bytes).context("generated isolator component is not valid")?; + + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_memory_module_validates() { + let m = build_memory_module(0); + let bytes = m.finish(); + wasmparser::validate(&bytes).expect("memory module should be valid"); + } + + #[test] + fn test_generate_isolator_basic() { + let targets = vec![ + IsolationTarget { + name: "main".to_string(), + prefix: "main_".to_string(), + }, + IsolationTarget { + name: "dep".to_string(), + prefix: "dep_".to_string(), + }, + ]; + generate_isolator(&targets, "0.2.3").expect("generated isolator component is not valid"); + } +} diff --git a/crates/env-isolator/src/lib.rs b/crates/env-isolator/src/lib.rs new file mode 100644 index 0000000000..ba8b5f3ccb --- /dev/null +++ b/crates/env-isolator/src/lib.rs @@ -0,0 +1,53 @@ +//! Environment variable isolation for composed Spin components. +//! +//! When composing multiple WebAssembly components, all components share the same +//! host `wasi:cli/environment` import by default. This crate generates an +//! **isolator component** that interposes on that import to give each component +//! its own filtered view of the environment, based on per-component prefixes. + +pub mod core_module; +pub mod isolator; +pub mod wrapper; + +/// Compute the environment variable prefix for a component ID or dependency name. +/// +/// Extracts the last meaningful segment from the name (the interface name for +/// package-style names like `foo:bar/baz`, or the full name for plain names), +/// converts to uppercase, replaces non-alphanumeric chars with underscores, +/// and appends a trailing underscore. +/// +/// # Examples +/// - `"my-app"` → `"MY_APP_"` +/// - `"worker"` → `"WORKER_"` +/// - `"foo:bar/baz"` → `"BAZ_"` +/// - `"hello:components/dep"` → `"DEP_"` +pub fn compute_prefix(id: &str) -> String { + // Extract the last segment: after the last '/' if present, otherwise the whole thing + let name_part = id.rsplit('/').next().unwrap_or(id); + let mut prefix: String = name_part + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect(); + prefix.push('_'); + prefix +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_prefix() { + assert_eq!(compute_prefix("my-app"), "MY_APP_"); + assert_eq!(compute_prefix("worker"), "WORKER_"); + assert_eq!(compute_prefix("main"), "MAIN_"); + assert_eq!(compute_prefix("foo:bar/baz"), "BAZ_"); + assert_eq!(compute_prefix("hello:components/dep"), "DEP_"); + } +} diff --git a/crates/env-isolator/src/wrapper.rs b/crates/env-isolator/src/wrapper.rs new file mode 100644 index 0000000000..a86d590602 --- /dev/null +++ b/crates/env-isolator/src/wrapper.rs @@ -0,0 +1,154 @@ +//! Wrapper component generation. +//! +//! Generates a WebAssembly component that imports a filtered `get-environment` +//! function from the isolator and the original `wasi:cli/environment` instance +//! (for `get-arguments` and `initial-cwd`), then re-exports them bundled as a +//! `wasi:cli/environment` instance. +//! +//! Each target component gets its own wrapper that bridges: +//! - Imports: `wasi:cli/environment@{version}` (passthrough), `environment-{name}-get-environment` +//! - Export: `wasi:cli/environment@{version}` instance +//! +//! Note that these wrappers don't incur any runtime overhead: they get fully optimized +//! away by the component linker. + +use anyhow::Result; +use wasm_encoder::{ + Alias, Component, ComponentAliasSection, ComponentExportKind, ComponentExportSection, + ComponentImportSection, ComponentInstanceSection, ComponentOuterAliasKind, ComponentTypeRef, + ComponentTypeSection, ComponentValType, InstanceType, PrimitiveValType, +}; + +/// Build a wrapper component that imports a filtered `get-environment` from +/// the isolator and `get-arguments`/`initial-cwd` from the original +/// `wasi:cli/environment` instance, then exports them bundled as a +/// `wasi:cli/environment` instance. +pub fn build_env_wrapper_component(target_name: &str, wasi_env_version: &str) -> Result> { + let mut component = Component::new(); + let mut types = ComponentTypeSection::new(); + + let tuple_idx = types.len(); + types.defined_type().tuple([ + ComponentValType::Primitive(PrimitiveValType::String), + ComponentValType::Primitive(PrimitiveValType::String), + ]); + + let list_tss_idx = types.len(); + types.defined_type().list(ComponentValType::Type(tuple_idx)); + + let list_s_idx = types.len(); + types + .defined_type() + .list(ComponentValType::Primitive(PrimitiveValType::String)); + + let option_s_idx = types.len(); + types + .defined_type() + .option(ComponentValType::Primitive(PrimitiveValType::String)); + + let get_env_idx = types.len(); + types + .function() + .params::<[(&str, ComponentValType); 0], ComponentValType>([]) + .result(Some(ComponentValType::Type(list_tss_idx))); + + let get_args_idx = types.len(); + types + .function() + .params::<[(&str, ComponentValType); 0], ComponentValType>([]) + .result(Some(ComponentValType::Type(list_s_idx))); + + let get_cwd_idx = types.len(); + types + .function() + .params::<[(&str, ComponentValType); 0], ComponentValType>([]) + .result(Some(ComponentValType::Type(option_s_idx))); + + let mut env_instance = InstanceType::new(); + env_instance.alias(Alias::Outer { + kind: ComponentOuterAliasKind::Type, + count: 1, + index: get_env_idx, + }); + env_instance.alias(Alias::Outer { + kind: ComponentOuterAliasKind::Type, + count: 1, + index: get_args_idx, + }); + env_instance.alias(Alias::Outer { + kind: ComponentOuterAliasKind::Type, + count: 1, + index: get_cwd_idx, + }); + env_instance.export("get-environment", ComponentTypeRef::Func(0)); + env_instance.export("get-arguments", ComponentTypeRef::Func(1)); + env_instance.export("initial-cwd", ComponentTypeRef::Func(2)); + let env_instance_idx = types.len(); + types.instance(&env_instance); + + component.section(&types); + + let mut imports = ComponentImportSection::new(); + imports.import( + &format!("wasi:cli/environment@{wasi_env_version}"), + ComponentTypeRef::Instance(env_instance_idx), + ); + imports.import( + &format!("environment-{target_name}-get-environment"), + ComponentTypeRef::Func(get_env_idx), + ); + component.section(&imports); + + // Alias get-arguments and initial-cwd from the imported instance + let mut aliases = ComponentAliasSection::new(); + aliases.alias(Alias::InstanceExport { + instance: 0, + kind: ComponentExportKind::Func, + name: "get-arguments", + }); + aliases.alias(Alias::InstanceExport { + instance: 0, + kind: ComponentExportKind::Func, + name: "initial-cwd", + }); + component.section(&aliases); + + // Bundle: func 0 = get-environment (imported), func 1 = get-arguments (aliased), + // func 2 = initial-cwd (aliased) + let mut instances = ComponentInstanceSection::new(); + instances.export_items([ + ("get-environment", ComponentExportKind::Func, 0), + ("get-arguments", ComponentExportKind::Func, 1), + ("initial-cwd", ComponentExportKind::Func, 2), + ]); + component.section(&instances); + + let mut exports = ComponentExportSection::new(); + exports.export( + &format!("wasi:cli/environment@{wasi_env_version}"), + ComponentExportKind::Instance, + 1, + Some(ComponentTypeRef::Instance(env_instance_idx)), + ); + component.section(&exports); + + let bytes = component.finish(); + + #[cfg(debug_assertions)] + wasmparser::validate(&bytes) + .map_err(|e| anyhow::anyhow!("generated env wrapper component is not valid: {e}"))?; + + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_wrapper() { + let bytes = + build_env_wrapper_component("main", "0.2.3").expect("wrapper generation failed"); + wasmparser::validate(&bytes).expect("wrapper component should be valid"); + } +} diff --git a/crates/loader/Cargo.toml b/crates/loader/Cargo.toml index 0d85b5f577..2ca9eda8f3 100644 --- a/crates/loader/Cargo.toml +++ b/crates/loader/Cargo.toml @@ -16,6 +16,7 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } spin-common = { path = "../common" } +spin-env-isolator = { path = "../env-isolator" } spin-expressions = { path = "../expressions" } spin-locked-app = { path = "../locked-app" } spin-manifest = { path = "../manifest" } diff --git a/crates/loader/src/local.rs b/crates/loader/src/local.rs index 4cc6a23dc6..060f5048eb 100644 --- a/crates/loader/src/local.rs +++ b/crates/loader/src/local.rs @@ -177,7 +177,8 @@ impl LocalLoader { ) .await?; - let env = component.environment.into_iter().collect(); + let env = + Self::build_component_env(id.as_ref(), component.environment, &component.dependencies); let files = if component.files.is_empty() { vec![] @@ -239,6 +240,45 @@ impl LocalLoader { }) } + /// Build the env map for a locked component. + /// + /// When env isolation is active (at least one dependency exists), all env + /// vars are prefixed so the isolator can route them to the correct + /// component at runtime. Dependency env vars are merged into the + /// component's env map (prefixed), so the locked app carries a single, + /// flat, already-prefixed env map per component. + fn build_component_env( + component_id: &str, + component_environment: impl IntoIterator, + manifest_deps: &v2::ComponentDependencies, + ) -> BTreeMap { + if manifest_deps.inner.is_empty() { + // Don't prefix env vars if there are no dependencies. + return component_environment.into_iter().collect(); + } + + let mut env = BTreeMap::new(); + + // Prefix the component's own env vars. + let main_prefix = spin_env_isolator::compute_prefix(component_id); + for (k, v) in component_environment { + env.insert(format!("{main_prefix}{k}"), v); + } + + // Merge each dependency's env vars (prefixed) into the same map. + for (dep_name, dep) in &manifest_deps.inner { + let dep_env = dep.environment(); + if !dep_env.is_empty() { + let prefix = spin_env_isolator::compute_prefix(&dep_name.to_string()); + for (k, v) in dep_env { + env.insert(format!("{prefix}{k}"), v.clone()); + } + } + } + + env + } + async fn load_component_dependencies( &self, id: &KebabId, @@ -822,6 +862,7 @@ impl WasmLoader { registry, package, export, + .. } => { let version = semver::VersionReq::parse(&version).with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid semantic version requirement ({version:?}) for its package version"))?; @@ -855,7 +896,7 @@ impl WasmLoader { .await?; Ok((content, export)) } - v2::ComponentDependency::Local { path, export } => { + v2::ComponentDependency::Local { path, export, .. } => { let content = self.app_root.join(path); Ok((content, export)) } @@ -863,6 +904,7 @@ impl WasmLoader { url, digest, export, + .. } => { let content = self.load_http_source(&url, &digest).await?; Ok((content, export)) @@ -941,4 +983,78 @@ mod test { ); Ok(()) } + + #[test] + fn build_component_env_prefixes_when_isolation_needed() { + let dep_name: DependencyName = "hello:components/dependable".parse().unwrap(); + let mut locked_deps = BTreeMap::new(); + locked_deps.insert( + dep_name.clone(), + LockedComponentDependency { + source: LockedComponentSource { + content_type: "application/wasm".into(), + content: ContentRef::default(), + }, + export: None, + inherit: Default::default(), + }, + ); + + let manifest_deps = v2::ComponentDependencies { + inner: std::iter::once(( + dep_name, + v2::ComponentDependency::Local { + path: "dep.wasm".into(), + export: None, + environment: std::iter::once(( + "GREETING".to_owned(), + "hello from dep".to_owned(), + )) + .collect(), + }, + )) + .collect(), + }; + + let component_environment = vec![ + ("GREETING".to_owned(), "hello from main".to_owned()), + ("MAIN_ONLY".to_owned(), "only visible to main".to_owned()), + ]; + + let env = LocalLoader::build_component_env("main", component_environment, &manifest_deps); + + // Component's own vars should be prefixed with MAIN_ + assert_eq!( + env.get("MAIN_GREETING").map(String::as_str), + Some("hello from main") + ); + assert_eq!( + env.get("MAIN_MAIN_ONLY").map(String::as_str), + Some("only visible to main") + ); + // Dependency's vars should be prefixed with DEPENDABLE_ + assert_eq!( + env.get("DEPENDABLE_GREETING").map(String::as_str), + Some("hello from dep") + ); + // Unprefixed keys should not exist + assert!(!env.contains_key("GREETING")); + } + + #[test] + fn build_component_env_no_prefixing_without_dependencies() { + let component_environment = vec![("GREETING".to_owned(), "hello".to_owned())]; + + let env = LocalLoader::build_component_env( + "main", + component_environment, + &v2::ComponentDependencies { + inner: Default::default(), + }, + ); + + // No dependencies means no isolation — env should be unprefixed + assert_eq!(env.get("GREETING").map(String::as_str), Some("hello")); + assert!(!env.contains_key("MAIN_GREETING")); + } } diff --git a/crates/manifest/src/normalize.rs b/crates/manifest/src/normalize.rs index b421433a50..b7b6e469a1 100644 --- a/crates/manifest/src/normalize.rs +++ b/crates/manifest/src/normalize.rs @@ -120,13 +120,18 @@ fn normalize_dependency_component_refs(manifest: &mut AppManifest) -> anyhow::Re if let ComponentDependency::AppComponent { component: depended_on_id, export, + environment, } = dependency { let depended_on = components .get(depended_on_id) .with_context(|| format!("dependency ID {depended_on_id} does not exist"))?; ensure_is_acceptable_dependency(depended_on, depended_on_id, depender_id)?; - *dependency = component_source_to_dependency(&depended_on.source, export.clone()); + // Merge: depended-on component's env as base, dependency spec's env as overrides + let mut merged_env = depended_on.environment.clone(); + merged_env.extend(std::mem::take(environment)); + *dependency = + component_source_to_dependency(&depended_on.source, export.clone(), merged_env); } } } @@ -137,16 +142,19 @@ fn normalize_dependency_component_refs(manifest: &mut AppManifest) -> anyhow::Re fn component_source_to_dependency( source: &ComponentSource, export: Option, + environment: indexmap::IndexMap, ) -> ComponentDependency { match source { ComponentSource::Local(path) => ComponentDependency::Local { path: PathBuf::from(path), export, + environment, }, ComponentSource::Remote { url, digest } => ComponentDependency::HTTP { url: url.clone(), digest: digest.clone(), export, + environment, }, ComponentSource::Registry { registry, @@ -157,6 +165,7 @@ fn component_source_to_dependency( registry: registry.as_ref().map(|r| r.to_string()), package: Some(package.to_string()), export, + environment, }, } } @@ -179,7 +188,7 @@ fn ensure_is_acceptable_dependency( source: _, description: _, variables, - environment, + environment: _, files, exclude_files: _, allowed_http_hosts, @@ -206,9 +215,6 @@ fn ensure_is_acceptable_dependency( if !dependencies.inner.is_empty() { surprises.push("dependencies"); } - if !environment.is_empty() { - surprises.push("environment"); - } if !files.is_empty() { surprises.push("files"); } @@ -273,12 +279,18 @@ mod test { .get(&package_name("b:b")) .unwrap(); - let ComponentDependency::Local { path, export } = dep else { + let ComponentDependency::Local { + path, + export, + environment, + } = dep + else { panic!("should have normalised to local dep"); }; assert_eq!(&PathBuf::from("b.wasm"), path); assert_eq!(&None, export); + assert!(environment.is_empty()); } #[test] @@ -317,6 +329,7 @@ mod test { url, digest, export, + environment, } = dep else { panic!("should have normalised to HTTP dep"); @@ -325,6 +338,7 @@ mod test { assert_eq!("http://example.com/b.wasm", url); assert_eq!("12345", digest); assert_eq!("c:d/e", export.as_ref().unwrap()); + assert!(environment.is_empty()); } #[test] @@ -364,6 +378,7 @@ mod test { registry, package, export, + environment, } = dep else { panic!("should have normalised to HTTP dep"); @@ -373,5 +388,6 @@ mod test { assert_eq!("reginalds-registry.reg", registry.as_ref().unwrap()); assert_eq!("bb:bb", package.as_ref().unwrap()); assert_eq!(&None, export); + assert!(environment.is_empty()); } } diff --git a/crates/manifest/src/schema/v2.rs b/crates/manifest/src/schema/v2.rs index 6504798279..c6fe96f655 100644 --- a/crates/manifest/src/schema/v2.rs +++ b/crates/manifest/src/schema/v2.rs @@ -238,6 +238,10 @@ pub enum ComponentDependency { /// /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry export: Option, + /// Environment variables to set for this dependency. These are merged with + /// any environment variables declared on the referenced component. + #[serde(default, skip_serializing_if = "Map::is_empty")] + environment: Map, }, /// `... = { path = "path/to/component.wasm", export = "my-export" }` #[schemars(description = "")] // schema docs are on the parent @@ -254,6 +258,10 @@ pub enum ComponentDependency { /// /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-local-component export: Option, + /// Environment variables to set for this dependency. These are merged with + /// any environment variables declared on the referenced component. + #[serde(default, skip_serializing_if = "Map::is_empty")] + environment: Map, }, /// `... = { url = "https://example.com/component.wasm", sha256 = "..." }` #[schemars(description = "")] // schema docs are on the parent @@ -276,6 +284,10 @@ pub enum ComponentDependency { /// /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url export: Option, + /// Environment variables to set for this dependency. These are merged with + /// any environment variables declared on the referenced component. + #[serde(default, skip_serializing_if = "Map::is_empty")] + environment: Map, }, /// `... = { component = "my-dependency" }` #[schemars(description = "")] // schema docs are on the parent @@ -292,9 +304,27 @@ pub enum ComponentDependency { /// /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies export: Option, + /// Environment variables to set for this dependency. These override + /// the referenced component's own environment variables. + #[serde(default, skip_serializing_if = "Map::is_empty")] + environment: Map, }, } +impl ComponentDependency { + /// Returns the environment variables associated with this dependency, if any. + pub fn environment(&self) -> &Map { + static EMPTY: std::sync::LazyLock> = std::sync::LazyLock::new(Map::new); + match self { + Self::Version(_) => &EMPTY, + Self::Package { environment, .. } + | Self::Local { environment, .. } + | Self::HTTP { environment, .. } + | Self::AppComponent { environment, .. } => environment, + } + } +} + /// A Spin component. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] diff --git a/examples/env-var-isolation/.gitignore b/examples/env-var-isolation/.gitignore new file mode 100644 index 0000000000..f118df9709 --- /dev/null +++ b/examples/env-var-isolation/.gitignore @@ -0,0 +1,2 @@ +target +.spin \ No newline at end of file diff --git a/examples/env-var-isolation/Cargo.lock b/examples/env-var-isolation/Cargo.lock new file mode 100644 index 0000000000..47c026485c --- /dev/null +++ b/examples/env-var-isolation/Cargo.lock @@ -0,0 +1,1296 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dependable" +version = "0.1.0" +dependencies = [ + "wit-bindgen 0.53.1", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "main" +version = "0.1.0" +dependencies = [ + "anyhow", + "spin-sdk", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "postgres-protocol" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + +[[package]] +name = "postgres_range" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6dce28dc5ba143d8eb157b62aac01ae5a1c585c40792158b720e86a87642101" +dependencies = [ + "postgres-protocol", + "postgres-types", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.115", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "routefinder" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0971d3c8943a6267d6bd0d782fdc4afa7593e7381a92a3df950ff58897e066b5" +dependencies = [ + "smartcow", + "smartstring", +] + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smartcow" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656fcb1c1fca8c4655372134ce87d8afdf5ec5949ebabe8d314be0141d8b5da2" +dependencies = [ + "smartstring", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "spin-executor" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bba409d00af758cd5de128da4a801e891af0545138f66a688f025f6d4e33870b" +dependencies = [ + "futures", + "once_cell", + "wasi", +] + +[[package]] +name = "spin-macro" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f959f16928e3c023468e41da9ebb77442e2ce22315e8dab11508fe76b3567ee1" +dependencies = [ + "anyhow", + "bytes", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "spin-sdk" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8951c7c4ab7f87f332d497789eeed9631c8116988b628b4851eb2fa999ead019" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "form_urlencoded", + "futures", + "http", + "once_cell", + "postgres_range", + "routefinder", + "rust_decimal", + "serde", + "serde_json", + "spin-executor", + "spin-macro", + "thiserror", + "uuid", + "wasi", + "wit-bindgen 0.51.0", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.13.1+wasi-0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f43d1c36145feb89a3e61aa0ba3e582d976a8ab77f1474aa0adb80800fe0cf8" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.115", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" +dependencies = [ + "leb128fmt", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da55e60097e8b37b475a0fa35c3420dd71d9eb7bd66109978ab55faf56a57efb" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasmparser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" +dependencies = [ + "bitflags", + "hashbrown 0.16.1", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro 0.51.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e915216dde3e818093168df8380a64fba25df468d626c80dd5d6a184c87e7c7" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro 0.53.1", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3deda4b7e9f522d994906f6e6e0fc67965ea8660306940a776b76732be8f3933" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.245.1", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0780cf7046630ed70f689a098cd8d56c5c3b22f2a7379bbdb088879963ff96" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.115", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863a7ab3c4dfee58db196811caeb0718b88412a0aef3d1c2b02fcbae1e37c688" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.115", + "wasm-metadata 0.245.1", + "wit-bindgen-core 0.53.1", + "wit-component 0.245.1", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.115", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust 0.51.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d14f3a9bfa3804bb0e9ab7f66da047f210eded6a1297ae3ba5805b384d64797f" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.115", + "wit-bindgen-core 0.53.1", + "wit-bindgen-rust 0.53.1", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-component" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4894f10d2d5cbc17c77e91f86a1e48e191a788da4425293b55c98b44ba3fcac9" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.245.1", + "wasm-metadata 0.245.1", + "wasmparser 0.245.1", + "wit-parser 0.245.1", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929" +dependencies = [ + "anyhow", + "hashbrown 0.16.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.245.1", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/examples/env-var-isolation/Cargo.toml b/examples/env-var-isolation/Cargo.toml new file mode 100644 index 0000000000..e3cd3815cb --- /dev/null +++ b/examples/env-var-isolation/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = ["crates/main", "crates/dependable"] +resolver = "2" + +[workspace.dependencies] +wit-bindgen = "0.53.1" diff --git a/examples/env-var-isolation/README.md b/examples/env-var-isolation/README.md new file mode 100644 index 0000000000..e0cd757f63 --- /dev/null +++ b/examples/env-var-isolation/README.md @@ -0,0 +1,51 @@ +# Environment Variable Isolation Example + +This example demonstrates per-dependency environment variable isolation in a Spin application. + +The `main` component calls a dependency (`dependable`), and each component receives a different environment configuration, including different values for the same variable name. + +## Prerequisites + +Install [Rust](https://rustup.rs) and [Spin](https://github.com/spinframework/spin). + +If needed, add the WebAssembly target used by this example: + +```bash +rustup target add wasm32-wasip2 +``` + +## Building and Running + +From this directory, build and run the app: + +```bash +spin build +spin up +``` + +## Testing + +In another terminal, send a request: + +```bash +curl -s http://127.0.0.1:3000/ +``` + +## What to look for + +The response includes two lines: + +- `main's env vars: ...` +- `dependable's env vars: ...` + +You should see that environment values are isolated per component: + +- `MAIN_ONLY` appears only in `main`. +- `DEPENDABLE_ONLY` appears only in `dependable`. +- `GREETING` is different for each component (`hello from main` vs `hello from dependable`). + +## How it works + +- `spin.toml` defines component-level environment variables for `main` and dependency-specific variables for `hello:components/dependable`. +- `crates/main/src/lib.rs` returns `main` env values and calls the dependency. +- `crates/dependable/src/lib.rs` returns env values seen by the dependency component. diff --git a/examples/env-var-isolation/crates/dependable/Cargo.toml b/examples/env-var-isolation/crates/dependable/Cargo.toml new file mode 100644 index 0000000000..ecf431d7dd --- /dev/null +++ b/examples/env-var-isolation/crates/dependable/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "dependable" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/examples/env-var-isolation/crates/dependable/src/lib.rs b/examples/env-var-isolation/crates/dependable/src/lib.rs new file mode 100644 index 0000000000..28f81190fb --- /dev/null +++ b/examples/env-var-isolation/crates/dependable/src/lib.rs @@ -0,0 +1,20 @@ +wit_bindgen::generate!({ + world: "dependable-world", + path: "../../wit", +}); + +pub struct Dependable; + +impl exports::hello::components::dependable::Guest for Dependable { + fn get_message() -> String { + format!( + "dependable's env vars: {}", + std::env::vars() + .map(|(key, value)| format!("{key}='{value}'")) + .collect::>() + .join(", ") + ) + } +} + +export!(Dependable); diff --git a/examples/env-var-isolation/crates/main/Cargo.toml b/examples/env-var-isolation/crates/main/Cargo.toml new file mode 100644 index 0000000000..25a5ba13de --- /dev/null +++ b/examples/env-var-isolation/crates/main/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "main" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = "1" +spin-sdk = "5" diff --git a/examples/env-var-isolation/crates/main/src/lib.rs b/examples/env-var-isolation/crates/main/src/lib.rs new file mode 100644 index 0000000000..96b3d2823f --- /dev/null +++ b/examples/env-var-isolation/crates/main/src/lib.rs @@ -0,0 +1,27 @@ +use spin_sdk::http::{IntoResponse, Request, Response}; +use spin_sdk::http_component; + +use crate::hello::components::dependable; + +/// A simple Spin HTTP component that demonstrates per-dependency env isolation. +#[http_component] +fn handle_hello_rust(_req: Request) -> anyhow::Result { + let body = format!("{}\n{}\n", get_message(), dependable::get_message()); + Ok(Response::new(200, body)) +} + +fn get_message() -> String { + format!( + "main's env vars: {}", + std::env::vars() + .map(|(key, value)| format!("{key}='{value}'")) + .collect::>() + .join(", ") + ) +} + +spin_sdk::wit_bindgen::generate!({ + world: "main", + path: "../../wit", + runtime_path: "::spin_sdk::wit_bindgen::rt", +}); diff --git a/examples/env-var-isolation/spin.toml b/examples/env-var-isolation/spin.toml new file mode 100644 index 0000000000..b288e920e8 --- /dev/null +++ b/examples/env-var-isolation/spin.toml @@ -0,0 +1,27 @@ +spin_manifest_version = 2 + +[application] +name = "env-isolator-hello" +version = "0.1.0" +description = "Example demonstrating per-dependency environment variable isolation" + +[[trigger.http]] +route = "/..." +component = "main" + +[component.main] +source = "target/wasm32-wasip2/release/main.wasm" +environment = { GREETING = "hello from main", "MAIN_ONLY" = "only visible to main" } + +[component.main.build] +command = "cargo build --target wasm32-wasip2 --release" + +[component.main.dependencies."hello:components/dependable"] +component = "dependable" +environment = { GREETING = "hello from dependable", "DEPENDABLE_ONLY" = "only visible to dependable" } + +[component.dependable] +source = "target/wasm32-wasip2/release/dependable.wasm" + +[component.dependable.build] +command = "cargo build --target wasm32-wasip2 --release" diff --git a/examples/env-var-isolation/wit/hello.wit b/examples/env-var-isolation/wit/hello.wit new file mode 100644 index 0000000000..90ac5a51b0 --- /dev/null +++ b/examples/env-var-isolation/wit/hello.wit @@ -0,0 +1,13 @@ +package hello:components; + +interface dependable { + get-message: func() -> string; +} + +world main { + import dependable; +} + +world dependable-world { + export dependable; +} diff --git a/examples/spin-timer/Cargo.lock b/examples/spin-timer/Cargo.lock index a0ce29fc1a..c52df4c48b 100644 --- a/examples/spin-timer/Cargo.lock +++ b/examples/spin-timer/Cargo.lock @@ -4840,6 +4840,7 @@ dependencies = [ "spin-app", "spin-common", "spin-componentize", + "spin-env-isolator", "spin-serde", "thiserror 2.0.17", "tokio", @@ -4856,6 +4857,15 @@ dependencies = [ "wasmtime", ] +[[package]] +name = "spin-env-isolator" +version = "3.7.0-pre0" +dependencies = [ + "anyhow", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + [[package]] name = "spin-expressions" version = "3.7.0-pre0" diff --git a/src/commands/up.rs b/src/commands/up.rs index 46635e1882..a92a1bcd5a 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -520,7 +520,10 @@ impl UpCommand { } fn update_locked_app(&self, locked_app: &mut LockedApp) { - // Apply --env to component environments + // Apply --env to component environments. + // Dynamic env vars are added as-is: if env isolation is active, + // the env map is already prefixed from lock time, and the isolator + // will route vars by prefix. The user provides the prefix they want. if !self.env.is_empty() { for component in locked_app.components.iter_mut() { component.env.extend(self.env.iter().cloned()); @@ -924,4 +927,78 @@ mod test { assert_eq!("-L", groups[2][0]); assert_eq!("/fie", groups[2][1]); } + + #[test] + fn dynamic_env_vars_passed_through_unchanged() { + use spin_app::locked::{ + ContentRef, LockedComponent, LockedComponentDependency, LockedComponentSource, + }; + + let dep_name = "hello:components/dependable".parse().unwrap(); + let source = LockedComponentSource { + content_type: "application/wasm".into(), + content: ContentRef::default(), + }; + + // Simulate a locked app where env vars are already prefixed from lock time + let mut locked_app = LockedApp { + spin_lock_version: Default::default(), + must_understand: vec![], + metadata: Default::default(), + host_requirements: Default::default(), + variables: Default::default(), + triggers: vec![], + components: vec![LockedComponent { + id: "main".into(), + metadata: Default::default(), + source: source.clone(), + env: [ + ("MAIN_GREETING".into(), "hello from main".into()), + ("DEPENDABLE_GREETING".into(), "hello from dep".into()), + ] + .into_iter() + .collect(), + files: vec![], + config: Default::default(), + dependencies: [( + dep_name, + LockedComponentDependency { + source, + export: None, + inherit: Default::default(), + }, + )] + .into_iter() + .collect(), + host_requirements: Default::default(), + }], + }; + + let cmd = UpCommand { + env: vec![ + ("MAIN_FOO".into(), "bar".into()), + ("DEPENDABLE_BAZ".into(), "zorp".into()), + ], + ..Default::default() + }; + + cmd.update_locked_app(&mut locked_app); + + let main = &locked_app.components[0]; + // Dynamic vars are added as-is — the isolator routes them by prefix + assert_eq!(main.env.get("MAIN_FOO").map(String::as_str), Some("bar")); + assert_eq!( + main.env.get("DEPENDABLE_BAZ").map(String::as_str), + Some("zorp") + ); + // Original prefixed env preserved + assert_eq!( + main.env.get("MAIN_GREETING").map(String::as_str), + Some("hello from main") + ); + assert_eq!( + main.env.get("DEPENDABLE_GREETING").map(String::as_str), + Some("hello from dep") + ); + } }