diff --git a/Cargo.lock b/Cargo.lock index fb515db..fac72e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,11 +363,12 @@ checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" [[package]] name = "conformance-tests" version = "0.1.0" -source = "git+https://github.com/fermyon/conformance-tests#cfee7a519b59f4318d5a19358ec3f2bf1bc23f41" +source = "git+https://github.com/fermyon/conformance-tests?branch=main#4eb7a94fbd1b640babd89e729df380ef4cd84fe6" dependencies = [ "anyhow", "flate2", "json5", + "libtest-mimic", "reqwest", "serde", "tar", @@ -2366,6 +2367,7 @@ name = "spin-test-virt" version = "0.1.0" dependencies = [ "anyhow", + "ipnet", "spin-expressions", "spin-manifest", "spin-outbound-networking", @@ -2530,7 +2532,7 @@ dependencies = [ [[package]] name = "test-environment" version = "0.1.0" -source = "git+https://github.com/fermyon/conformance-tests#cfee7a519b59f4318d5a19358ec3f2bf1bc23f41" +source = "git+https://github.com/fermyon/conformance-tests?branch=main#4eb7a94fbd1b640babd89e729df380ef4cd84fe6" dependencies = [ "anyhow", "fslock", diff --git a/conformance-tests/Cargo.toml b/conformance-tests/Cargo.toml index 2d70cd5..e33d9d2 100644 --- a/conformance-tests/Cargo.toml +++ b/conformance-tests/Cargo.toml @@ -5,8 +5,8 @@ edition = "2021" [dependencies] anyhow = "1.0" -conformance-tests = { git = "https://github.com/fermyon/conformance-tests" } -test-environment = { git = "https://github.com/fermyon/conformance-tests" } +conformance-tests = { git = "https://github.com/fermyon/conformance-tests", branch = "main" } +test-environment = { git = "https://github.com/fermyon/conformance-tests", branch = "main" } spin-test = { path = ".." } wasmtime = "21.0" wasmtime-wasi = "21.0" diff --git a/conformance-tests/src/main.rs b/conformance-tests/src/main.rs index 5cf2db4..f8e2d02 100644 --- a/conformance-tests/src/main.rs +++ b/conformance-tests/src/main.rs @@ -7,49 +7,53 @@ use runtime::SpinTest; const HTTP_PORT: u16 = 1234; fn main() -> anyhow::Result<()> { - let tests_dir = conformance_tests::download_tests()?; + conformance_tests::run_tests_from("../../conformance-tests/conformance-tests", |test| { + run_test(test) + }) +} - for test in conformance_tests::tests(&tests_dir)? { - println!("Test: {}", test.name); - let mut manifest = - test_environment::manifest_template::EnvTemplate::from_file(&test.manifest)?; - let env_config = test_environment::TestEnvironmentConfig { - create_runtime: Box::new(|_env| { - manifest.substitute_value("port", |port| { - (port == "80").then(|| HTTP_PORT.to_string()) - })?; - SpinTest::new(manifest.into_contents(), test.component) - }), - // Services are not needed in `spin-test` since everything stays in the guest - services_config: test_environment::services::ServicesConfig::none(), - }; - let mut env = test_environment::TestEnvironment::up(env_config, |_| Ok(())).unwrap(); - for precondition in &test.config.preconditions { - match precondition { - conformance_tests::config::Precondition::HttpEcho => { - env.runtime_mut() - .set_echo_response(format!("http://localhost:{HTTP_PORT}").as_str())?; - } - conformance_tests::config::Precondition::KeyValueStore(_) => {} +fn run_test(test: conformance_tests::Test) -> Result<(), anyhow::Error> { + let mut manifest = test_environment::manifest_template::EnvTemplate::from_file(&test.manifest)?; + let env_config = test_environment::TestEnvironmentConfig { + create_runtime: Box::new(|_env| { + manifest.substitute_value("port", |port| substitution("port", port))?; + SpinTest::new(manifest.into_contents(), test.component) + }), + // Services are not needed in `spin-test` since everything stays in the guest + services_config: test_environment::services::ServicesConfig::none(), + }; + let mut env = test_environment::TestEnvironment::up(env_config, |_| Ok(()))?; + for precondition in &test.config.preconditions { + match precondition { + conformance_tests::config::Precondition::HttpEcho => { + env.runtime_mut() + .set_echo_response(format!("http://localhost:{HTTP_PORT}").as_str())?; } + conformance_tests::config::Precondition::TcpEcho => {} + conformance_tests::config::Precondition::KeyValueStore(_) => {} } - for invocation in test.config.invocations { - let conformance_tests::config::Invocation::Http(mut invocation) = invocation; - invocation - .request - .substitute(|key, value| { - Ok(match (key, value) { - ("port", "80") => Some(HTTP_PORT.to_string()), - _ => None, - }) - }) - .unwrap(); - invocation.run(|request| env.runtime_mut().make_http_request(request))?; - } } + for invocation in test.config.invocations { + let conformance_tests::config::Invocation::Http(mut invocation) = invocation; + invocation + .request + .substitute(|key, value| Ok(substitution(key, value)))?; + + invocation.run(|request| env.runtime_mut().make_http_request(request))?; + } + Ok(()) } +/// When encountering a magic key-value pair, substitute the value with a different value. +fn substitution(key: &str, value: &str) -> Option { + match (key, value) { + ("port", "80") => Some(HTTP_PORT.to_string()), + ("port", "5000") => Some(5000.to_string()), + _ => None, + } +} + struct StoreData { manifest: String, ctx: wasmtime_wasi::WasiCtx, diff --git a/crates/spin-test-virt/Cargo.toml b/crates/spin-test-virt/Cargo.toml index 37d03cf..109f086 100644 --- a/crates/spin-test-virt/Cargo.toml +++ b/crates/spin-test-virt/Cargo.toml @@ -5,12 +5,13 @@ edition = "2021" [dependencies] anyhow = "1.0" -wit-bindgen-rt = { version = "0.25.0", features = ["bitflags"] } +ipnet = "2.9" spin-expressions = { workspace = true } spin-manifest = { workspace = true } spin-outbound-networking = { workspace = true } spin-serde = { workspace = true } toml = { workspace = true } +wit-bindgen-rt = { version = "0.25.0", features = ["bitflags"] } [lib] diff --git a/crates/spin-test-virt/src/manifest.rs b/crates/spin-test-virt/src/manifest.rs index 3c3b707..7b9018e 100644 --- a/crates/spin-test-virt/src/manifest.rs +++ b/crates/spin-test-virt/src/manifest.rs @@ -4,16 +4,18 @@ use std::sync::{OnceLock, RwLock}; pub struct AppManifest; impl AppManifest { - /// Returns whether the given URL is allowed by the manifest. - pub fn allows_url(url: &str, scheme: &str) -> anyhow::Result { + /// Returns the allowed hosts configuration for the current component. + pub fn allowed_hosts() -> anyhow::Result { let allowed_outbound_hosts = Self::get_component() .expect("internal error: component id not yet set") .normalized_allowed_outbound_hosts()?; let resolver = spin_expressions::PreparedResolver::default(); - let allowed_hosts = spin_outbound_networking::AllowedHostsConfig::parse( - &allowed_outbound_hosts, - &resolver, - )?; + spin_outbound_networking::AllowedHostsConfig::parse(&allowed_outbound_hosts, &resolver) + } + + /// Returns whether the given URL is allowed by the manifest. + pub fn allows_url(url: &str, scheme: &str) -> anyhow::Result { + let allowed_hosts = Self::allowed_hosts()?; let url = spin_outbound_networking::OutboundUrl::parse(url, scheme)?; Ok(allowed_hosts.allows(&url)) } diff --git a/crates/spin-test-virt/src/wasi.rs b/crates/spin-test-virt/src/wasi.rs index 74ee81c..4d65a8c 100644 --- a/crates/spin-test-virt/src/wasi.rs +++ b/crates/spin-test-virt/src/wasi.rs @@ -5,6 +5,7 @@ mod filesystem; pub mod http; pub mod http_helper; pub mod io; +mod tcp; use crate::bindings::exports::wasi; use crate::Component; @@ -152,10 +153,18 @@ impl wasi::cli::exit::Guest for Component { impl wasi::sockets::instance_network::Guest for Component { fn instance_network() -> wasi::sockets::instance_network::Network { - todo!() + wasi::sockets::instance_network::Network::new(Network) } } +impl wasi::sockets::network::Guest for Component { + type Network = Network; +} + +pub struct Network; + +impl wasi::sockets::network::GuestNetwork for Network {} + impl wasi::sockets::ip_name_lookup::Guest for Component { type ResolveAddressStream = ResolveAddressStream; @@ -187,187 +196,6 @@ impl wasi::sockets::ip_name_lookup::GuestResolveAddressStream for ResolveAddress } } -impl wasi::sockets::network::Guest for Component { - type Network = Network; -} - -pub struct Network; - -impl wasi::sockets::network::GuestNetwork for Network {} - -impl wasi::sockets::tcp::Guest for Component { - type TcpSocket = TcpSocket; -} - -pub struct TcpSocket; - -impl wasi::sockets::tcp::GuestTcpSocket for TcpSocket { - fn start_bind( - &self, - network: wasi::sockets::tcp::NetworkBorrow<'_>, - local_address: wasi::sockets::tcp::IpSocketAddress, - ) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn finish_bind(&self) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn start_connect( - &self, - network: wasi::sockets::tcp::NetworkBorrow<'_>, - remote_address: wasi::sockets::tcp::IpSocketAddress, - ) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn finish_connect( - &self, - ) -> Result< - ( - wasi::sockets::tcp::InputStream, - wasi::sockets::tcp::OutputStream, - ), - wasi::sockets::tcp::ErrorCode, - > { - todo!() - } - - fn start_listen(&self) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn finish_listen(&self) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn accept( - &self, - ) -> Result< - ( - wasi::sockets::tcp::TcpSocket, - wasi::sockets::tcp::InputStream, - wasi::sockets::tcp::OutputStream, - ), - wasi::sockets::tcp::ErrorCode, - > { - todo!() - } - - fn local_address( - &self, - ) -> Result { - todo!() - } - - fn remote_address( - &self, - ) -> Result { - todo!() - } - - fn is_listening(&self) -> bool { - todo!() - } - - fn address_family(&self) -> wasi::sockets::tcp::IpAddressFamily { - todo!() - } - - fn set_listen_backlog_size(&self, value: u64) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn keep_alive_enabled(&self) -> Result { - todo!() - } - - fn set_keep_alive_enabled(&self, value: bool) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn keep_alive_idle_time( - &self, - ) -> Result { - todo!() - } - - fn set_keep_alive_idle_time( - &self, - value: wasi::sockets::tcp::Duration, - ) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn keep_alive_interval( - &self, - ) -> Result { - todo!() - } - - fn set_keep_alive_interval( - &self, - value: wasi::sockets::tcp::Duration, - ) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn keep_alive_count(&self) -> Result { - todo!() - } - - fn set_keep_alive_count(&self, value: u32) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn hop_limit(&self) -> Result { - todo!() - } - - fn set_hop_limit(&self, value: u8) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn receive_buffer_size(&self) -> Result { - todo!() - } - - fn set_receive_buffer_size(&self, value: u64) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn send_buffer_size(&self) -> Result { - todo!() - } - - fn set_send_buffer_size(&self, value: u64) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } - - fn subscribe(&self) -> wasi::sockets::tcp::Pollable { - todo!() - } - - fn shutdown( - &self, - shutdown_type: wasi::sockets::tcp::ShutdownType, - ) -> Result<(), wasi::sockets::tcp::ErrorCode> { - todo!() - } -} - -impl wasi::sockets::tcp_create_socket::Guest for Component { - fn create_tcp_socket( - address_family: wasi::sockets::tcp_create_socket::IpAddressFamily, - ) -> Result< - wasi::sockets::tcp_create_socket::TcpSocket, - wasi::sockets::tcp_create_socket::ErrorCode, - > { - todo!() - } -} - impl wasi::sockets::udp::Guest for Component { type UdpSocket = UdpSocket; type IncomingDatagramStream = IncomingDatagramStream; diff --git a/crates/spin-test-virt/src/wasi/tcp.rs b/crates/spin-test-virt/src/wasi/tcp.rs new file mode 100644 index 0000000..0b428ed --- /dev/null +++ b/crates/spin-test-virt/src/wasi/tcp.rs @@ -0,0 +1,282 @@ +use core::hash; + +use crate::bindings::exports::wasi::{self, sockets::network::Ipv4SocketAddress}; +use crate::Component; + +use super::io::{Buffer, InputStream, OutputStream}; + +impl wasi::sockets::tcp_create_socket::Guest for Component { + fn create_tcp_socket( + address_family: wasi::sockets::tcp_create_socket::IpAddressFamily, + ) -> Result< + wasi::sockets::tcp_create_socket::TcpSocket, + wasi::sockets::tcp_create_socket::ErrorCode, + > { + Ok(wasi::sockets::tcp_create_socket::TcpSocket::new(TcpSocket)) + } +} + +impl wasi::sockets::tcp::Guest for Component { + type TcpSocket = TcpSocket; +} + +pub struct TcpSocket; + +impl wasi::sockets::tcp::GuestTcpSocket for TcpSocket { + fn start_bind( + &self, + network: wasi::sockets::tcp::NetworkBorrow<'_>, + local_address: wasi::sockets::tcp::IpSocketAddress, + ) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn finish_bind(&self) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn start_connect( + &self, + network: wasi::sockets::tcp::NetworkBorrow<'_>, + remote_address: wasi::sockets::tcp::IpSocketAddress, + ) -> Result<(), wasi::sockets::tcp::ErrorCode> { + let allowed_hosts = crate::manifest::AppManifest::allowed_hosts() + .map_err(|e| wasi::sockets::tcp::ErrorCode::PermanentResolverFailure)?; + let configs = match allowed_hosts { + // If all hosts are allowed, then we can skip the rest of the checks. + spin_outbound_networking::AllowedHostsConfig::All => return Ok(()), + spin_outbound_networking::AllowedHostsConfig::SpecificHosts(configs) => configs, + }; + + let (remote_address, remote_port) = match remote_address { + wasi::sockets::network::IpSocketAddress::Ipv4(i) => ( + std::net::IpAddr::V4(std::net::Ipv4Addr::new( + i.address.0, + i.address.1, + i.address.2, + i.address.3, + )), + i.port, + ), + wasi::sockets::network::IpSocketAddress::Ipv6(i) => ( + std::net::IpAddr::V6(std::net::Ipv6Addr::new( + i.address.0, + i.address.1, + i.address.2, + i.address.3, + i.address.4, + i.address.5, + i.address.6, + i.address.7, + )), + i.port, + ), + }; + + for config in configs { + // Check if the port is allowed. + let mut allowed_port = false; + match config.port() { + spin_outbound_networking::PortConfig::Any => { + allowed_port = true; + break; + } + spin_outbound_networking::PortConfig::List(l) => { + for port in l { + match port { + spin_outbound_networking::IndividualPortConfig::Port(p) + if *p == remote_port => + { + allowed_port = true; + break; + } + spin_outbound_networking::IndividualPortConfig::Range(r) + if r.contains(&remote_port) => + { + allowed_port = true; + break; + } + _ => {} + } + } + } + } + if !allowed_port { + return Err(wasi::sockets::tcp::ErrorCode::AccessDenied); + } + + // If the scheme isn't a `*`, then this config does not grant access. + if !config.scheme().allows_any() { + continue; + } + + match config.host() { + spin_outbound_networking::HostConfig::AnySubdomain(_) + | spin_outbound_networking::HostConfig::ToSelf => continue, + spin_outbound_networking::HostConfig::Any => return Ok(()), + spin_outbound_networking::HostConfig::List(hosts) => { + // Check if any host is a CIDR block that contains the remote address. + for host in hosts { + // Parse the host as an `IpNet` cidr block and if it fails + // then try parsing again with `/32` appended to the end. + let Ok(ip_net) = host + .parse::() + .or_else(|_| format!("{host}/32").parse()) + else { + continue; + }; + if ip_net.contains(&remote_address) { + return Ok(()); + } + } + } + spin_outbound_networking::HostConfig::Cidr(ip_net) => { + // Check if the host is a CIDR block that contains the remote address. + if ip_net.contains(&remote_address) { + return Ok(()); + } + } + } + } + + Err(wasi::sockets::tcp::ErrorCode::AccessDenied) + } + + fn finish_connect( + &self, + ) -> Result< + ( + wasi::sockets::tcp::InputStream, + wasi::sockets::tcp::OutputStream, + ), + wasi::sockets::tcp::ErrorCode, + > { + let shared = Buffer::empty(); + Ok(( + wasi::sockets::tcp::InputStream::new(InputStream::Buffered(shared.clone())), + wasi::sockets::tcp::OutputStream::new(OutputStream::Buffered(shared)), + )) + } + + fn start_listen(&self) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn finish_listen(&self) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn accept( + &self, + ) -> Result< + ( + wasi::sockets::tcp::TcpSocket, + wasi::sockets::tcp::InputStream, + wasi::sockets::tcp::OutputStream, + ), + wasi::sockets::tcp::ErrorCode, + > { + todo!() + } + + fn local_address( + &self, + ) -> Result { + todo!() + } + + fn remote_address( + &self, + ) -> Result { + todo!() + } + + fn is_listening(&self) -> bool { + todo!() + } + + fn address_family(&self) -> wasi::sockets::tcp::IpAddressFamily { + todo!() + } + + fn set_listen_backlog_size(&self, value: u64) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn keep_alive_enabled(&self) -> Result { + todo!() + } + + fn set_keep_alive_enabled(&self, value: bool) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn keep_alive_idle_time( + &self, + ) -> Result { + todo!() + } + + fn set_keep_alive_idle_time( + &self, + value: wasi::sockets::tcp::Duration, + ) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn keep_alive_interval( + &self, + ) -> Result { + todo!() + } + + fn set_keep_alive_interval( + &self, + value: wasi::sockets::tcp::Duration, + ) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn keep_alive_count(&self) -> Result { + todo!() + } + + fn set_keep_alive_count(&self, value: u32) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn hop_limit(&self) -> Result { + todo!() + } + + fn set_hop_limit(&self, value: u8) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn receive_buffer_size(&self) -> Result { + todo!() + } + + fn set_receive_buffer_size(&self, value: u64) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn send_buffer_size(&self) -> Result { + todo!() + } + + fn set_send_buffer_size(&self, value: u64) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } + + fn subscribe(&self) -> wasi::sockets::tcp::Pollable { + todo!() + } + + fn shutdown( + &self, + shutdown_type: wasi::sockets::tcp::ShutdownType, + ) -> Result<(), wasi::sockets::tcp::ErrorCode> { + todo!() + } +}