Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ subprocess = "0.2"
tempfile = { workspace = true }
tokio = { workspace = true, features = ["full"] }
toml = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
uuid = { version = "1.0", features = ["v4"] }
Expand All @@ -55,6 +56,7 @@ watchexec-filterer-globset = "8.0"

spin-app = { path = "crates/app" }
spin-build = { path = "crates/build" }
spin-capabilities = { path = "crates/capabilities" }
spin-common = { path = "crates/common" }
spin-connection-semaphore = { path = "crates/connection-semaphore" }
spin-dependency-wit = { path = "crates/dependency-wit" }
Expand All @@ -69,6 +71,7 @@ spin-manifest = { path = "crates/manifest" }
spin-oci = { path = "crates/oci" }
spin-plugins = { path = "crates/plugins" }
spin-runtime-factors = { path = "crates/runtime-factors" }
spin-serde = { path = "crates/serde" }
spin-telemetry = { path = "crates/telemetry", features = [
"tracing-log-compat",
] }
Expand Down
163 changes: 163 additions & 0 deletions crates/capabilities/src/collect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
use std::collections::BTreeSet;

use wac_graph::types::are_semver_compatible;
use wasmparser::{Parser, Payload};

use crate::CAPABILITY_SETS;

/// Infer the Spin capability sets a component requires, by inspecting its
/// top-level (component) imports.
///
/// Each import is matched against the known capability sets (`ai_models`,
/// `allowed_outbound_hosts`, etc.); every set that matches contributes its name
/// to the result. The returned set is deduplicated and sorted. An empty set
/// means the component imports nothing that maps to a Spin capability.
pub fn required_capabilities(source: &[u8]) -> anyhow::Result<BTreeSet<String>> {
let mut capabilities = BTreeSet::new();
let mut depth: u32 = 0;

for payload in Parser::new(0).parse_all(source) {
match payload? {
Payload::ModuleSection { .. } | Payload::ComponentSection { .. } => {
depth += 1;
}
Payload::End(_) if depth > 0 => {
depth -= 1;
}
Payload::ComponentImportSection(reader) if depth == 0 => {
for import in reader {
let name = import?.name.0;
for &(capability, set) in CAPABILITY_SETS {
if set.iter().any(|s| are_semver_compatible(name, s)) {
capabilities.insert(capability.to_string());
}
}
}
}
_ => {}
}
}

Ok(capabilities)
}

#[cfg(test)]
mod tests {
use super::*;

/// Build a minimal Wasm component that imports the given interface names.
fn build_component(import_names: &[&str]) -> Vec<u8> {
use wasm_encoder::{
Component, ComponentImportSection, ComponentTypeRef, ComponentTypeSection, InstanceType,
};

let mut component = Component::new();

// Define one empty instance type to reference from all imports.
let mut types = ComponentTypeSection::new();
types.instance(&InstanceType::new());
component.section(&types);

let mut imports = ComponentImportSection::new();
for name in import_names {
imports.import(name, ComponentTypeRef::Instance(0));
}
component.section(&imports);

component.finish()
}

fn caps(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|s| s.to_string()).collect()
}

#[test]
fn no_matching_imports_returns_empty() {
let bytes = build_component(&["some:unknown/interface@1.0.0"]);
assert!(required_capabilities(&bytes).unwrap().is_empty());
}

#[test]
fn empty_component_returns_empty() {
let bytes = wasm_encoder::Component::new().finish();
assert!(required_capabilities(&bytes).unwrap().is_empty());
}

#[test]
fn single_ai_models_import() {
let bytes = build_component(&["fermyon:spin/llm@2.0.0"]);
assert_eq!(required_capabilities(&bytes).unwrap(), caps(&["ai_models"]));
}

#[test]
fn single_allowed_outbound_hosts_import() {
let bytes = build_component(&["wasi:http/outgoing-handler@0.2.6"]);
assert_eq!(
required_capabilities(&bytes).unwrap(),
caps(&["allowed_outbound_hosts"])
);
}

#[test]
fn multiple_capabilities_deduped() {
let bytes = build_component(&[
"fermyon:spin/llm@2.0.0",
"wasi:http/outgoing-handler@0.2.6",
"wasi:sockets/tcp@0.2.6",
"fermyon:spin/variables@2.0.0",
]);
assert_eq!(
required_capabilities(&bytes).unwrap(),
caps(&["ai_models", "allowed_outbound_hosts", "variables"])
);
}

#[test]
fn all_capability_sets_detected() {
let bytes = build_component(&[
"fermyon:spin/llm@2.0.0", // ai_models
"wasi:http/outgoing-handler@0.2.6", // allowed_outbound_hosts
"wasi:cli/environment@0.2.6", // environment
"wasi:filesystem/preopens@0.2.6", // files
"fermyon:spin/key-value@2.0.0", // key_value_stores
"fermyon:spin/sqlite@2.0.0", // sqlite_databases
"fermyon:spin/variables@2.0.0", // variables
]);
assert_eq!(
required_capabilities(&bytes).unwrap(),
caps(&[
"ai_models",
"allowed_outbound_hosts",
"environment",
"files",
"key_value_stores",
"sqlite_databases",
"variables",
])
);
}

#[test]
fn duplicate_set_entries_are_deduped() {
let bytes =
build_component(&["wasi:http/outgoing-handler@0.2.6", "wasi:sockets/tcp@0.2.6"]);
// Both map to allowed_outbound_hosts — should appear once.
Comment thread
fibonacci1729 marked this conversation as resolved.
assert_eq!(
required_capabilities(&bytes).unwrap(),
caps(&["allowed_outbound_hosts"])
);
}

#[test]
fn mixed_known_and_unknown_imports() {
let bytes = build_component(&[
"fermyon:spin/llm@2.0.0",
"some:unknown/thing@1.0.0",
"wasi:cli/environment@0.2.6",
]);
assert_eq!(
required_capabilities(&bytes).unwrap(),
caps(&["ai_models", "environment"])
);
}
}
2 changes: 2 additions & 0 deletions crates/capabilities/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
pub use collect::required_capabilities;
pub use deny::apply_deny_adapter;
mod collect;
mod deny;

/// Specifies which host capabilities a component dependency is allowed to inherit
Expand Down
93 changes: 93 additions & 0 deletions crates/dependency-wit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,99 @@ use wit_parser::Span;

const GENERATED_COMMENT: &str = "// This file is automatically generated by Spin\n// It is not intended for manual editing.\n\n";

/// List the exports of a Wasm component as formatted dependency name strings.
///
/// Interface exports from a package are formatted as
/// `namespace:package/interface@version`. Exports declared with a bare name
/// (for example an exported function, or an inline interface) are returned as
/// that plain kebab-case name.
pub fn list_exports(wasm_bytes: &[u8]) -> anyhow::Result<Vec<String>> {
let decoded = read_wasm(wasm_bytes)?;
let (resolve, world_id) = match &decoded {
DecodedWasm::WitPackage(resolve, pkg_id) => {
let world_id = resolve.select_world(&[*pkg_id], None)?;
(resolve, world_id)
}
DecodedWasm::Component(resolve, world_id) => (resolve, *world_id),
};

let world = resolve
.worlds
.get(world_id)
.context("world not found in decoded Wasm")?;

let mut exports = Vec::new();
for (key, _item) in &world.exports {
match key {
wit_parser::WorldKey::Name(name) => {
exports.push(name.clone());
}
wit_parser::WorldKey::Interface(iface_id) => {
if let Some(iface) = resolve.interfaces.get(*iface_id)
&& let Some(pkg_id) = iface.package
&& let Some(pkg) = resolve.packages.get(pkg_id)
{
let ns = &pkg.name.namespace;
let name = &pkg.name.name;
let iface_name = iface.name.as_deref().unwrap_or("unknown");
if let Some(version) = &pkg.name.version {
exports.push(format!("{ns}:{name}/{iface_name}@{version}"));
} else {
exports.push(format!("{ns}:{name}/{iface_name}"));
}
}
}
}
}

Ok(exports)
}

/// Returns `true` if the component is HTTP middleware: it both *imports* and
/// *exports* the `wasi:http/handler` interface (any version).
///
/// A regular HTTP handler only exports `handler`; middleware also imports it so
/// it can forward the request to the next component in the pipeline.
pub fn is_http_middleware(wasm_bytes: &[u8]) -> anyhow::Result<bool> {
let decoded = read_wasm(wasm_bytes)?;
let (resolve, world_id) = match &decoded {
DecodedWasm::WitPackage(resolve, pkg_id) => {
let world_id = resolve.select_world(&[*pkg_id], None)?;
(resolve, world_id)
}
DecodedWasm::Component(resolve, world_id) => (resolve, *world_id),
};

let world = resolve
.worlds
.get(world_id)
.context("world not found in decoded Wasm")?;

Ok(contains_wasi_http_handler(resolve, world.imports.keys())
&& contains_wasi_http_handler(resolve, world.exports.keys()))
}

fn contains_wasi_http_handler<'a>(
resolve: &wit_parser::Resolve,
keys: impl Iterator<Item = &'a wit_parser::WorldKey>,
) -> bool {
keys.filter_map(|key| match key {
wit_parser::WorldKey::Interface(id) => resolve.interfaces.get(*id),
wit_parser::WorldKey::Name(_) => None,
})
.any(|iface| {
let Some(pkg_id) = iface.package else {
return false;
};
let Some(pkg) = resolve.packages.get(pkg_id) else {
return false;
};
pkg.name.namespace == "wasi"
&& pkg.name.name == "http"
&& iface.name.as_deref() == Some("handler")
})
}

pub async fn extract_wits_into(
source: impl ExactSizeIterator<Item = (&DependencyName, &ComponentDependency)>,
app_root: impl AsRef<Path>,
Expand Down
2 changes: 2 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
pub mod build;
/// Commands for publishing applications to the Fermyon Platform.
pub mod cloud;
/// Commands for managing component dependencies.
pub mod deps;
/// Command for running the Spin Doctor.
pub mod doctor;
/// Commands for external subcommands (i.e. plugins)
Expand Down
Loading
Loading