use crate::parse::config::Injection;
use anyhow::Context;
use cviz::model::{compatible_fingerprints, ExportInfo};
use cviz::parse::component::{parse_component, parse_component_imports};
use std::collections::{BTreeMap, HashMap};
use std::fs;
include!(concat!(env!("OUT_DIR"), "/tier_interfaces.rs"));
pub fn versioned_interface(iface: &str, version: &str) -> String {
format!("{iface}@{version}")
}
#[derive(Clone, Debug, PartialEq)]
pub enum ContractResult {
Ok,
Warn(String),
Error(String),
Tier1Compatible(Vec<String>),
Tier2Compatible(Vec<String>),
}
pub fn validate_contract(
to_inject: &[Injection],
interface_name: &str,
contract_fingerprint: &Option<String>,
checked_middlewares: &mut HashMap<String, BTreeMap<String, ExportInfo>>,
) -> Vec<ContractResult> {
let mut results = vec![];
for Injection { name, path, .. } in to_inject.iter() {
if !checked_middlewares.contains_key(name.as_str()) {
match discover_middleware_exports(path) {
Ok(exports) => {
checked_middlewares.insert(name.clone(), exports);
}
Err(err) => {
results.push(ContractResult::Warn(format!(
"Unable to load middleware '{name}': {err:#}"
)));
continue;
}
}
}
let exports = checked_middlewares.get(name.as_str()).unwrap();
if let Some(ExportInfo { fingerprint, .. }) = exports.get(interface_name) {
if !compatible_fingerprints(contract_fingerprint, fingerprint) {
results.push(ContractResult::Error(format!(
"incompatible type signatures for middleware '{}' on interface '{}'\n\t{name}:\t{fingerprint:?}\n\ttarget: {contract_fingerprint:?}",
name, interface_name
)));
} else {
results.push(ContractResult::Ok);
}
} else {
results.push(classify_tier_export(name, exports, path, interface_name));
}
}
results
}
fn classify_tier_export(
name: &str,
exports: &BTreeMap<String, ExportInfo>,
path: &Option<String>,
interface_name: &str,
) -> ContractResult {
let tier1 = match_tier_interfaces(exports, TIER1_INTERFACES, TIER1_VERSION);
let tier2 = match_tier_interfaces(exports, TIER2_INTERFACES, TIER2_VERSION);
if !tier1.is_empty() && !tier2.is_empty() {
return ContractResult::Error(format!(
"middleware '{name}' exports interfaces from multiple tiers \
(tier 1: {tier1_list}; tier 2: {tier2_list}).\n\n\
A middleware must implement exactly one tier. To combine \
behaviors, ship them as separate components and chain \
them in `inject: [...]`.",
tier1_list = tier1.join(", "),
tier2_list = tier2.join(", "),
));
}
if !tier1.is_empty() {
if !is_adapter_eligible(path, interface_name) {
return warn_not_exported(name, interface_name);
}
return ContractResult::Tier1Compatible(tier1);
}
if !tier2.is_empty() {
if !is_adapter_eligible(path, interface_name) {
return warn_not_exported(name, interface_name);
}
return ContractResult::Tier2Compatible(tier2);
}
warn_not_exported(name, interface_name)
}
fn warn_not_exported(name: &str, interface_name: &str) -> ContractResult {
ContractResult::Warn(format!(
"Middleware '{}' does not export interface '{}'.\n\
\tIt cannot be spliced on this interface.\n\
\tCheck that the middleware both imports and re-exports '{}',\n\
\tor that the interface name in your config exactly matches\n\
\twhat the middleware binary exports.",
name, interface_name, interface_name
))
}
fn is_compatible_interface(export_name: &str, iface: &str, expected_version: &str) -> bool {
if export_name == iface {
return true;
}
let Some(rest) = export_name.strip_prefix(iface) else {
return false;
};
let Some(version_str) = rest.strip_prefix('@') else {
return false;
};
let Ok(export_ver) = semver::Version::parse(version_str) else {
return false;
};
let Ok(req) = semver::VersionReq::parse(&format!("^{expected_version}")) else {
return false;
};
req.matches(&export_ver)
}
fn match_tier_interfaces(
exports: &BTreeMap<String, ExportInfo>,
tier_ifaces: &[&str],
tier_version: &str,
) -> Vec<String> {
tier_ifaces
.iter()
.filter(|iface| {
exports
.keys()
.any(|export_name| is_compatible_interface(export_name, iface, tier_version))
})
.map(|iface| iface.to_string())
.collect()
}
fn is_adapter_eligible(middleware_path: &Option<String>, target_interface: &str) -> bool {
let Some(path) = middleware_path else {
return false;
};
let Ok(buff) = fs::read(path) else {
return false;
};
let Ok(imports) = parse_component_imports(&buff) else {
return false;
};
!imports.iter().any(|(name, _)| name == target_interface)
}
fn discover_middleware_exports(
wasm_path: &Option<String>,
) -> anyhow::Result<BTreeMap<String, ExportInfo>> {
let Some(path) = wasm_path else {
return Ok(BTreeMap::default());
};
let buff = fs::read(path).with_context(|| format!("failed to read '{path}'"))?;
let graph = parse_component(&buff)
.with_context(|| format!("failed to parse Wasm component '{path}'"))?;
Ok(graph.component_exports)
}
#[cfg(test)]
mod tests {
use super::*;
use cviz::model::ExportInfo;
#[test]
fn exact_match_no_version() {
assert!(is_compatible_interface(
"splicer:tier1/before",
"splicer:tier1/before",
"0.1.0"
));
}
#[test]
fn versioned_match_same_version() {
assert!(is_compatible_interface(
"splicer:tier1/before@0.1.0",
"splicer:tier1/before",
"0.1.0"
));
}
#[test]
fn versioned_match_patch_bump() {
assert!(is_compatible_interface(
"splicer:tier1/before@0.1.3",
"splicer:tier1/before",
"0.1.0"
));
}
#[test]
fn versioned_mismatch_minor_bump_0x() {
assert!(!is_compatible_interface(
"splicer:tier1/before@0.2.0",
"splicer:tier1/before",
"0.1.0"
));
}
#[test]
fn versioned_mismatch_different_name() {
assert!(!is_compatible_interface(
"splicer:tier1/after@0.1.0",
"splicer:tier1/before",
"0.1.0"
));
}
#[test]
fn versioned_match_major_1x_minor_bump() {
assert!(is_compatible_interface(
"splicer:tier1/before@1.2.0",
"splicer:tier1/before",
"1.0.0"
));
}
#[test]
fn versioned_mismatch_major_bump() {
assert!(!is_compatible_interface(
"splicer:tier1/before@2.0.0",
"splicer:tier1/before",
"1.0.0"
));
}
fn discover_exports_from_bytes(bytes: &[u8]) -> BTreeMap<String, ExportInfo> {
let graph = parse_component(bytes).expect("Unable to parse component");
graph.component_exports
}
fn injection(name: &str) -> Injection {
Injection {
name: name.to_string(),
adapter_info: None,
tier: None,
builtin: None,
builtin_config: Default::default(),
config_as_wave: None,
config_provider_path: None,
path: None,
}
}
fn export_with_fingerprint(fp: &str) -> ExportInfo {
ExportInfo {
source_instance: 0,
fingerprint: Some(fp.to_string()),
ty: None,
}
}
fn cache_with(
mw_name: &str,
interface: &str,
fp: &str,
) -> HashMap<String, BTreeMap<String, ExportInfo>> {
let mut exports = BTreeMap::new();
exports.insert(interface.to_string(), export_with_fingerprint(fp));
let mut cache = HashMap::new();
cache.insert(mw_name.to_string(), exports);
cache
}
#[test]
fn warn_when_no_path() {
let mut cache = HashMap::new();
let results = validate_contract(&[injection("mw")], "wasi:http/handler", &None, &mut cache);
assert_eq!(results.len(), 1);
assert!(
matches!(results[0], ContractResult::Warn(_)),
"expected Warn, got {:?}",
results[0]
);
}
#[test]
fn warn_when_interface_not_in_exports() {
let mut exports = BTreeMap::new();
exports.insert(
"other:pkg/other".to_string(),
export_with_fingerprint("fp-x"),
);
let mut cache = HashMap::new();
cache.insert("mw".to_string(), exports);
let results = validate_contract(
&[injection("mw")],
"wasi:http/handler",
&Some("fp-a".to_string()),
&mut cache,
);
assert_eq!(results.len(), 1);
assert!(
matches!(results[0], ContractResult::Warn(_)),
"expected Warn, got {:?}",
results[0]
);
}
#[test]
fn warn_for_each_injection_without_path() {
let injections = vec![injection("mw-a"), injection("mw-b"), injection("mw-c")];
let mut cache = HashMap::new();
let results = validate_contract(&injections, "wasi:http/handler", &None, &mut cache);
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| matches!(r, ContractResult::Warn(_))));
}
#[test]
fn error_when_fingerprints_incompatible() {
let mut cache = cache_with("mw", "wasi:http/handler", "fp-b");
let results = validate_contract(
&[injection("mw")],
"wasi:http/handler",
&Some("fp-a".to_string()),
&mut cache,
);
assert_eq!(results.len(), 1);
assert!(
matches!(results[0], ContractResult::Error(_)),
"expected Error, got {:?}",
results[0]
);
}
#[test]
fn ok_when_fingerprints_match() {
let mut cache = cache_with("mw", "wasi:http/handler", "fp-a");
let results = validate_contract(
&[injection("mw")],
"wasi:http/handler",
&Some("fp-a".to_string()),
&mut cache,
);
assert_eq!(results.len(), 1);
assert_eq!(results[0], ContractResult::Ok);
}
fn exports_for(ifaces: &[&str]) -> BTreeMap<String, ExportInfo> {
let mut out = BTreeMap::new();
for iface in ifaces {
out.insert(
iface.to_string(),
ExportInfo {
source_instance: 0,
fingerprint: None,
ty: None,
},
);
}
out
}
#[test]
fn match_tier_interfaces_picks_up_versioned_exports() {
let exports = exports_for(&["splicer:tier2/before@0.1.0", "splicer:tier2/after@0.1.7"]);
let matched = match_tier_interfaces(&exports, TIER2_INTERFACES, TIER2_VERSION);
assert_eq!(matched.len(), 2);
assert!(matched.iter().any(|i| i == "splicer:tier2/before"));
assert!(matched.iter().any(|i| i == "splicer:tier2/after"));
}
#[test]
fn match_tier_interfaces_skips_unrelated_packages() {
let exports = exports_for(&["other:pkg/iface", "wasi:http/handler@0.3.0"]);
let matched = match_tier_interfaces(&exports, TIER2_INTERFACES, TIER2_VERSION);
assert!(matched.is_empty());
}
#[test]
fn multi_tier_export_is_rejected_with_clear_error() {
let mut cache = HashMap::new();
cache.insert(
"mw".to_string(),
exports_for(&[
&versioned_interface("splicer:tier1/before", TIER1_VERSION),
&versioned_interface("splicer:tier2/after", TIER2_VERSION),
]),
);
let results = validate_contract(
&[injection("mw")],
"wasi:http/handler@0.3.0",
&None,
&mut cache,
);
assert_eq!(results.len(), 1);
let ContractResult::Error(msg) = &results[0] else {
panic!("expected Error, got {:?}", results[0]);
};
assert!(
msg.contains("multiple tiers"),
"error message should mention multi-tier rejection: {msg}"
);
assert!(
msg.contains("tier 1") && msg.contains("tier 2"),
"error message should name both tiers: {msg}"
);
}
#[test]
fn tier2_only_without_path_falls_through_to_warn() {
let mut cache = HashMap::new();
cache.insert(
"mw".to_string(),
exports_for(&["splicer:tier2/before@0.1.0"]),
);
let results = validate_contract(
&[injection("mw")],
"wasi:http/handler@0.3.0",
&None,
&mut cache,
);
assert_eq!(results.len(), 1);
assert!(
matches!(results[0], ContractResult::Warn(_)),
"expected Warn (no path), got {:?}",
results[0]
);
}
#[test]
fn ok_when_both_fingerprints_none() {
let mut cache = HashMap::new();
let mut exports = BTreeMap::new();
exports.insert(
"wasi:http/handler".to_string(),
ExportInfo {
source_instance: 0,
fingerprint: None,
ty: None,
},
);
cache.insert("mw".to_string(), exports);
let results = validate_contract(&[injection("mw")], "wasi:http/handler", &None, &mut cache);
assert_eq!(results.len(), 1);
assert_eq!(results[0], ContractResult::Ok);
}
#[test]
fn discover_exports_from_from_exports_mw() {
let wat = r#"(component
(import "wasi:http/handler@0.3.0" (instance $host
(export "handle" (func (param "req" u32) (result u32)))
))
(alias export $host "handle" (func $f))
(instance $out (export "handle" (func $f)))
(export "wasi:http/handler@0.3.0" (instance $out))
)"#;
let bytes = wat::parse_str(wat).expect("failed to parse WAT");
let exports = discover_exports_from_bytes(&bytes);
let export = exports
.get("wasi:http/handler@0.3.0")
.expect("expected export for wasi:http/handler@0.3.0");
assert!(
export.fingerprint.is_some(),
"expected fingerprint for FromExports middleware"
);
}
#[test]
fn discover_exports_from_passthrough_mw() {
let wat = r#"(component
(import "wasi:http/handler@0.3.0" (instance $handler
(export "handle" (func (param "req" u32) (result u32)))
))
(export "wasi:http/handler@0.3.0" (instance $handler))
)"#;
let bytes = wat::parse_str(wat).expect("failed to parse WAT");
let exports = discover_exports_from_bytes(&bytes);
let export = exports
.get("wasi:http/handler@0.3.0")
.expect("expected export for wasi:http/handler@0.3.0");
assert!(
export.fingerprint.is_some(),
"expected fingerprint for import-reexport middleware"
);
}
#[test]
fn ok_result_for_compatible_wat_middleware() {
let chain_wat = r#"(component
(import "wasi:http/handler@0.3.0" (instance $host
(export "handle" (func (param "req" u32) (result u32)))
))
(alias export $host "handle" (func $f))
(instance $out (export "handle" (func $f)))
(export "wasi:http/handler@0.3.0" (instance $out))
)"#;
let mw_wat = r#"(component
(import "wasi:http/handler@0.3.0" (instance $handler
(export "handle" (func (param "req" u32) (result u32)))
))
(export "wasi:http/handler@0.3.0" (instance $handler))
)"#;
let chain_bytes = wat::parse_str(chain_wat).expect("failed to parse chain WAT");
let mw_bytes = wat::parse_str(mw_wat).expect("failed to parse middleware WAT");
let chain_exports = discover_exports_from_bytes(&chain_bytes);
let chain_fp = chain_exports
.get("wasi:http/handler@0.3.0")
.and_then(|e| e.fingerprint.clone());
let mw_exports = discover_exports_from_bytes(&mw_bytes);
let mut cache = HashMap::new();
cache.insert("mw".to_string(), mw_exports);
let inj = Injection {
name: "mw".to_string(),
adapter_info: None,
tier: None,
builtin: None,
builtin_config: Default::default(),
config_as_wave: None,
config_provider_path: None,
path: None,
};
let results = validate_contract(&[inj], "wasi:http/handler@0.3.0", &chain_fp, &mut cache);
assert_eq!(results.len(), 1);
assert_eq!(results[0], ContractResult::Ok);
}
#[test]
fn error_result_for_incompatible_wat_middleware() {
let chain_wat = r#"(component
(import "wasi:http/handler@0.3.0" (instance $host
(export "handle" (func (param "req" u32) (result u32)))
))
(alias export $host "handle" (func $f))
(instance $out (export "handle" (func $f)))
(export "wasi:http/handler@0.3.0" (instance $out))
)"#;
let mw_wat = r#"(component
(import "wasi:http/handler@0.3.0" (instance $handler
(export "handle" (func (param "req" string) (result u32)))
))
(export "wasi:http/handler@0.3.0" (instance $handler))
)"#;
let chain_bytes = wat::parse_str(chain_wat).expect("failed to parse chain WAT");
let mw_bytes = wat::parse_str(mw_wat).expect("failed to parse middleware WAT");
let chain_exports = discover_exports_from_bytes(&chain_bytes);
let chain_fp = chain_exports
.get("wasi:http/handler@0.3.0")
.and_then(|e| e.fingerprint.clone());
let mw_exports = discover_exports_from_bytes(&mw_bytes);
let mut cache = HashMap::new();
cache.insert("mw".to_string(), mw_exports);
let inj = Injection {
name: "mw".to_string(),
adapter_info: None,
tier: None,
builtin: None,
builtin_config: Default::default(),
config_as_wave: None,
config_provider_path: None,
path: None,
};
let results = validate_contract(&[inj], "wasi:http/handler@0.3.0", &chain_fp, &mut cache);
assert_eq!(results.len(), 1);
assert!(
matches!(results[0], ContractResult::Error(_)),
"expected Error for incompatible middleware"
);
}
#[test]
fn mixed_results_for_multiple_injections() {
let injections = vec![
injection("mw-ok"),
injection("mw-bad"),
injection("mw-unknown"),
];
let mut cache = HashMap::new();
cache.insert("mw-ok".to_string(), {
let mut m = BTreeMap::new();
m.insert(
"wasi:http/handler".to_string(),
export_with_fingerprint("fp-a"),
);
m
});
cache.insert("mw-bad".to_string(), {
let mut m = BTreeMap::new();
m.insert(
"wasi:http/handler".to_string(),
export_with_fingerprint("fp-b"),
);
m
});
let results = validate_contract(
&injections,
"wasi:http/handler",
&Some("fp-a".to_string()),
&mut cache,
);
assert_eq!(results.len(), 3);
assert_eq!(results[0], ContractResult::Ok);
assert!(matches!(results[1], ContractResult::Error(_)));
assert!(matches!(results[2], ContractResult::Warn(_)));
}
}