use std::collections::BTreeSet;
use std::fs;
use std::io::{self, Cursor, Read, Seek};
use std::path::Path;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use wasmparser::{Encoding, Parser, Payload};
use zip::ZipArchive;
use crate::{
payload_shape::{select_root_wasm_in_archive, PayloadShapeError},
zip_guard,
};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WitContracts {
#[serde(default)]
pub exports: Vec<String>,
#[serde(default)]
pub imports: Vec<String>,
}
impl WitContracts {
pub fn all(&self) -> impl Iterator<Item = &str> {
self.exports
.iter()
.chain(self.imports.iter())
.map(String::as_str)
}
#[must_use]
pub fn matching_prefix(&self, prefix: &str) -> Vec<String> {
let mut matches: Vec<String> = self
.all()
.filter(|name| name.starts_with(prefix))
.map(str::to_string)
.collect();
sort_dedup(&mut matches);
matches
}
#[must_use]
pub fn unserved_against(&self, served: &[(&str, &str)]) -> Vec<UnservedInterface> {
let exports = self
.exports
.iter()
.map(|name| (name, ContractDirection::Export));
let imports = self
.imports
.iter()
.map(|name| (name, ContractDirection::Import));
exports
.chain(imports)
.filter_map(|(name, direction)| {
let (package, rest) = name.split_once('/')?;
if !package.starts_with("murmur:") {
return None;
}
let artifact_version = rest.rsplit_once('@').map(|(_, version)| version);
let served_version = served
.iter()
.find(|(served_package, _)| *served_package == package)
.map(|(_, version)| *version);
if artifact_version.is_some() && artifact_version == served_version {
return None;
}
Some(UnservedInterface {
interface: name.clone(),
direction,
package: package.to_string(),
artifact_version: artifact_version.map(str::to_string),
served_version: served_version.map(str::to_string),
})
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContractDirection {
Export,
Import,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnservedInterface {
pub interface: String,
pub direction: ContractDirection,
pub package: String,
pub artifact_version: Option<String>,
pub served_version: Option<String>,
}
#[derive(Debug, Error)]
pub enum WitContractError {
#[error("could not parse wasm payload: {0}")]
Parse(#[from] wasmparser::BinaryReaderError),
}
pub fn extract_wit_contracts(
component_bytes: &[u8],
) -> Result<Option<WitContracts>, WitContractError> {
let mut contracts = WitContracts::default();
let mut is_component = false;
let mut depth: usize = 0;
for payload in Parser::new(0).parse_all(component_bytes) {
match payload? {
Payload::Version { encoding, .. } => {
if depth == 0 {
is_component = encoding == Encoding::Component;
if !is_component {
return Ok(None);
}
}
}
Payload::ModuleSection { .. } | Payload::ComponentSection { .. } => depth += 1,
Payload::End(_) => depth = depth.saturating_sub(1),
Payload::ComponentExportSection(reader) if depth == 0 => {
for export in reader {
push_interface(&mut contracts.exports, export?.name.name);
}
}
Payload::ComponentImportSection(reader) if depth == 0 => {
for import in reader {
push_interface(&mut contracts.imports, import?.name.name);
}
}
_ => {}
}
}
if !is_component {
return Ok(None);
}
sort_dedup(&mut contracts.exports);
sort_dedup(&mut contracts.imports);
Ok(Some(contracts))
}
#[must_use]
pub fn wit_contracts_from_artifact_bytes(artifact_bytes: &[u8]) -> Option<WitContracts> {
wit_contracts_from_artifact_reader(Cursor::new(artifact_bytes))
}
#[must_use]
pub fn wit_contracts_from_artifact_reader<R: Read + Seek>(reader: R) -> Option<WitContracts> {
let mut archive = ZipArchive::new(reader).ok()?;
let wasm = read_root_wasm_from_archive(&mut archive)?;
extract_wit_contracts(&wasm).ok().flatten()
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ComponentReadError {
#[error("it is not a zip archive ({0})")]
NotAZip(String),
#[error("it has no root .wasm entry (expected capsule.wasm or one root *.wasm)")]
NoRootWasm,
#[error("its root .wasm entries are ambiguous: {}, and none is capsule.wasm", names.join(", "))]
AmbiguousRootWasm { names: Vec<String> },
#[error("its root .wasm entry {name} is over the {limit}-byte decompression cap")]
OverDecompressionCap { name: String, limit: u64 },
#[error("its root .wasm entry {name} cannot be read: {reason}")]
Unreadable { name: String, reason: String },
#[error("its root .wasm entry {name} is a core module, not a component")]
CoreModule { name: String },
#[error("its root .wasm entry {name} does not parse as a component: {reason}")]
NotAComponent { name: String, reason: String },
}
pub fn read_component_contracts(artifact_bytes: &[u8]) -> Result<WitContracts, ComponentReadError> {
let mut archive = ZipArchive::new(Cursor::new(artifact_bytes))
.map_err(|error| ComponentReadError::NotAZip(error.to_string()))?;
let name = select_root_wasm_in_archive(&mut archive).map_err(|error| match error {
PayloadShapeError::MissingRootWasm => ComponentReadError::NoRootWasm,
PayloadShapeError::MultipleRootWasm { names } => {
ComponentReadError::AmbiguousRootWasm { names }
}
})?;
let wasm = zip_guard::read_zip_entry_capped(
&mut archive,
&name,
zip_guard::max_artifact_decompressed_bytes(),
)
.map_err(|error| match error {
zip_guard::ZipGuardError::DecompressionCeilingExceeded { limit, .. } => {
ComponentReadError::OverDecompressionCap {
name: name.clone(),
limit,
}
}
other => ComponentReadError::Unreadable {
name: name.clone(),
reason: other.to_string(),
},
})?;
match extract_wit_contracts(&wasm) {
Ok(Some(contracts)) => Ok(contracts),
Ok(None) => Err(ComponentReadError::CoreModule { name }),
Err(WitContractError::Parse(error)) => Err(ComponentReadError::NotAComponent {
name,
reason: error
.to_string()
.split_whitespace()
.collect::<Vec<_>>()
.join(" "),
}),
}
}
fn read_root_wasm_from_archive<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Option<Vec<u8>> {
let selected = select_root_wasm_in_archive(archive).ok()?;
zip_guard::read_zip_entry_capped(
archive,
&selected,
zip_guard::max_artifact_decompressed_bytes(),
)
.ok()
}
pub fn wit_package_declarations(dir: &Path) -> io::Result<BTreeSet<(String, String)>> {
let mut declared = BTreeSet::new();
collect_package_declarations(dir, &mut declared)?;
Ok(declared)
}
fn collect_package_declarations(
dir: &Path,
into: &mut BTreeSet<(String, String)>,
) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_package_declarations(&path, into)?;
continue;
}
if path.extension().and_then(|ext| ext.to_str()) != Some("wit") {
continue;
}
for line in fs::read_to_string(&path)?.lines() {
let Some(declaration) = line
.trim()
.strip_prefix("package murmur:")
.and_then(|rest| rest.strip_suffix(';'))
else {
continue;
};
let (name, version) = declaration.split_once('@').ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("unversioned package in {}: {}", path.display(), line.trim()),
)
})?;
let name = name.strip_prefix('%').unwrap_or(name);
into.insert((format!("murmur:{name}"), version.to_string()));
}
}
Ok(())
}
fn push_interface(into: &mut Vec<String>, name: &str) {
if name.contains('/') {
into.push(name.to_string());
}
}
fn sort_dedup(names: &mut Vec<String>) {
names.sort();
names.dedup();
}
#[cfg(test)]
mod tests {
use std::io::Write;
use zip::{write::SimpleFileOptions, ZipWriter};
use super::*;
fn zip_with(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut zip = ZipWriter::new(Cursor::new(&mut buf));
for (name, bytes) in files {
zip.start_file(*name, SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
}
buf
}
fn component(wat: &str) -> Vec<u8> {
wat::parse_str(wat).unwrap()
}
const EXPORTING_COMPONENT: &str = r#"
(component
(core module $m (func (export "run")))
(core instance $i (instantiate $m))
(func $run (result u32) (canon lift (core func $i "run")))
(component $inner
(core module $n (func (export "nested")))
(core instance $ni (instantiate $n))
(func $nested (canon lift (core func $ni "nested")))
(instance $niface (export "nested" (func $nested)))
(export "murmur:nested/inner@9.9.9" (instance $niface))
)
(instance $iface (export "handle" (func $run)))
(export "murmur:hook/lifecycle@0.5.0" (instance $iface))
)
"#;
#[test]
fn records_the_outermost_components_exports_only() {
let contracts = extract_wit_contracts(&component(EXPORTING_COMPONENT))
.unwrap()
.unwrap();
assert_eq!(contracts.exports, vec!["murmur:hook/lifecycle@0.5.0"]);
assert!(contracts.imports.is_empty());
}
#[test]
fn records_imported_interface_names_sorted_and_deduplicated() {
let wat = r#"
(component
(import "murmur:tool-registry/invoke@0.1.0" (instance))
(import "wasi:cli/environment@0.2.0" (instance))
(import "murmur:runtime/inference@0.4.0" (instance))
)
"#;
let contracts = extract_wit_contracts(&component(wat)).unwrap().unwrap();
assert_eq!(
contracts.imports,
vec![
"murmur:runtime/inference@0.4.0",
"murmur:tool-registry/invoke@0.1.0",
"wasi:cli/environment@0.2.0",
]
);
}
#[test]
fn ignores_externs_that_do_not_name_an_interface() {
let wat = r#"
(component
(import "plain-func" (func))
(core module $m (func (export "f")))
(core instance $i (instantiate $m))
(func $f (canon lift (core func $i "f")))
(export "bare-export" (func $f))
)
"#;
let contracts = extract_wit_contracts(&component(wat)).unwrap().unwrap();
assert!(contracts.exports.is_empty());
assert!(contracts.imports.is_empty());
}
#[test]
fn a_core_module_yields_none() {
let module = component(r#"(module (func (export "f")))"#);
assert!(extract_wit_contracts(&module).unwrap().is_none());
}
#[test]
fn unparseable_bytes_are_an_error() {
assert!(extract_wit_contracts(b"\0asm").is_err());
}
#[test]
fn reads_the_component_out_of_an_artifact_zip() {
let archive = zip_with(&[
("murmur.yaml", b"name: demo\n"),
("tool.wasm", &component(EXPORTING_COMPONENT)),
]);
let contracts = wit_contracts_from_artifact_bytes(&archive).unwrap();
assert_eq!(contracts.exports, vec!["murmur:hook/lifecycle@0.5.0"]);
}
#[test]
fn capsule_wasm_wins_over_other_root_wasm_entries() {
let archive = zip_with(&[
("capsule.wasm", &component(EXPORTING_COMPONENT)),
("other.wasm", b"\0asm"),
]);
let contracts = wit_contracts_from_artifact_bytes(&archive).unwrap();
assert_eq!(contracts.exports, vec!["murmur:hook/lifecycle@0.5.0"]);
}
#[test]
fn unreadable_payloads_yield_none() {
assert!(wit_contracts_from_artifact_bytes(b"not-a-zip-at-all").is_none());
assert!(wit_contracts_from_artifact_bytes(&zip_with(&[("tool.wasm", b"\0asm")])).is_none());
assert!(
wit_contracts_from_artifact_bytes(&zip_with(&[("SKILL.md", b"# guidance")])).is_none()
);
assert!(
wit_contracts_from_artifact_bytes(&zip_with(&[("bin/demo", b"\x7fELF")])).is_none()
);
assert!(wit_contracts_from_artifact_bytes(&zip_with(&[
("a.wasm", b"\0asm"),
("b.wasm", b"\0asm"),
]))
.is_none());
let module = component(r#"(module (func (export "f")))"#);
assert!(wit_contracts_from_artifact_bytes(&zip_with(&[("tool.wasm", &module)])).is_none());
}
#[test]
fn the_strict_reader_reads_a_packed_component() {
let archive = zip_with(&[("capsule.wasm", &component(EXPORTING_COMPONENT))]);
assert_eq!(
read_component_contracts(&archive).ok(),
wit_contracts_from_artifact_bytes(&archive)
);
}
#[test]
fn the_strict_reader_names_why_a_payload_holds_no_component() {
assert!(matches!(
read_component_contracts(b"not-a-zip-at-all"),
Err(ComponentReadError::NotAZip(_))
));
assert_eq!(
read_component_contracts(&zip_with(&[("SKILL.md", b"# guidance")])),
Err(ComponentReadError::NoRootWasm)
);
assert_eq!(
read_component_contracts(&zip_with(&[("a.wasm", b"\0asm"), ("b.wasm", b"\0asm")])),
Err(ComponentReadError::AmbiguousRootWasm {
names: vec!["a.wasm".to_string(), "b.wasm".to_string()]
})
);
let module = component("(module)");
let error = read_component_contracts(&zip_with(&[("capsule.wasm", &module)])).unwrap_err();
assert_eq!(
error.to_string(),
"its root .wasm entry capsule.wasm is a core module, not a component"
);
let error =
read_component_contracts(&zip_with(&[("capsule.wasm", b"not wasm")])).unwrap_err();
assert!(
matches!(&error, ComponentReadError::NotAComponent { name, .. } if name == "capsule.wasm"),
"{error:?}"
);
assert!(
error
.to_string()
.starts_with("its root .wasm entry capsule.wasm does not parse as a component: "),
"{error}"
);
}
#[test]
fn reader_variant_reads_the_same_contracts_as_the_bytes_variant() {
let archive = zip_with(&[("tool.wasm", &component(EXPORTING_COMPONENT))]);
assert_eq!(
wit_contracts_from_artifact_reader(Cursor::new(&archive)),
wit_contracts_from_artifact_bytes(&archive)
);
assert!(wit_contracts_from_artifact_reader(Cursor::new(b"not-a-zip")).is_none());
}
const SERVED: &[(&str, &str)] = &[
("murmur:hook", "0.9.0"),
("murmur:runtime", "0.4.0"),
("murmur:text", "0.1.0"),
("murmur:tool", "0.1.0"),
];
fn contracts_of(exports: &[&str], imports: &[&str]) -> WitContracts {
WitContracts {
exports: exports.iter().map(|name| name.to_string()).collect(),
imports: imports.iter().map(|name| name.to_string()).collect(),
}
}
#[test]
fn unserved_skips_a_name_at_the_served_version() {
let contracts = contracts_of(
&["murmur:hook/lifecycle@0.9.0"],
&["murmur:runtime/inference@0.4.0"],
);
assert!(contracts.unserved_against(SERVED).is_empty());
}
#[test]
fn unserved_reports_a_retired_export_and_import_with_both_versions() {
let contracts = contracts_of(
&["murmur:hook/lifecycle@0.8.0"],
&["murmur:runtime/inference@0.3.0"],
);
assert_eq!(
contracts.unserved_against(SERVED),
vec![
UnservedInterface {
interface: "murmur:hook/lifecycle@0.8.0".to_string(),
direction: ContractDirection::Export,
package: "murmur:hook".to_string(),
artifact_version: Some("0.8.0".to_string()),
served_version: Some("0.9.0".to_string()),
},
UnservedInterface {
interface: "murmur:runtime/inference@0.3.0".to_string(),
direction: ContractDirection::Import,
package: "murmur:runtime".to_string(),
artifact_version: Some("0.3.0".to_string()),
served_version: Some("0.4.0".to_string()),
},
]
);
}
#[test]
fn unserved_reports_a_package_the_table_does_not_serve() {
let without_text: Vec<(&str, &str)> = SERVED
.iter()
.copied()
.filter(|(package, _)| *package != "murmur:text")
.collect();
let contracts = contracts_of(
&["murmur:tool/run@0.1.0"],
&["murmur:text/chunks@0.1.0", "murmur:text/stream@0.1.0"],
);
let unserved = contracts.unserved_against(&without_text);
assert_eq!(unserved.len(), 2, "{unserved:?}");
for interface in &unserved {
assert_eq!(interface.package, "murmur:text");
assert_eq!(interface.direction, ContractDirection::Import);
assert_eq!(interface.artifact_version.as_deref(), Some("0.1.0"));
assert_eq!(interface.served_version, None);
}
}
#[test]
fn unserved_reports_an_unversioned_name() {
let contracts = contracts_of(&["murmur:tool/run"], &[]);
let unserved = contracts.unserved_against(SERVED);
assert_eq!(unserved.len(), 1);
assert_eq!(unserved[0].package, "murmur:tool");
assert_eq!(unserved[0].artifact_version, None);
assert_eq!(unserved[0].served_version.as_deref(), Some("0.1.0"));
}
#[test]
fn unserved_never_reports_other_namespaces() {
let contracts = contracts_of(
&["foo:bar/baz@9.9.9"],
&["wasi:cli/environment@0.2.0", "foo:bar/baz@9.9.9"],
);
assert!(contracts.unserved_against(SERVED).is_empty());
}
#[test]
fn matching_prefix_spans_both_directions() {
let contracts = WitContracts {
exports: vec!["murmur:tool/run@0.1.0".to_string()],
imports: vec![
"murmur:tool-registry/invoke@0.1.0".to_string(),
"wasi:cli/environment@0.2.0".to_string(),
],
};
assert_eq!(
contracts.matching_prefix("murmur:tool"),
vec!["murmur:tool-registry/invoke@0.1.0", "murmur:tool/run@0.1.0",]
);
assert!(contracts.matching_prefix("murmur:hook").is_empty());
}
fn write(path: &Path, text: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
#[test]
fn package_declarations_are_read_recursively_with_the_keyword_escape_dropped() {
let tree = tempfile::tempdir().unwrap();
write(
&tree.path().join("worlds.wit"),
"package murmur:host@0.1.0;\n\nworld w {}\n",
);
write(
&tree.path().join("deps/murmur-stream/stream.wit"),
" package murmur:%stream@0.1.0;\ninterface events {}\n",
);
write(
&tree.path().join("deps/nested/deeper/task.wit"),
"package murmur:task@0.2.0;\n",
);
write(
&tree.path().join("deps/wasi/cli.wit"),
"package wasi:cli@0.2.0;\n",
);
write(
&tree.path().join("README.md"),
"package murmur:ignored@9.9.9;\n",
);
let declared = wit_package_declarations(tree.path()).unwrap();
let expected: BTreeSet<(String, String)> = [
("murmur:host", "0.1.0"),
("murmur:stream", "0.1.0"),
("murmur:task", "0.2.0"),
]
.into_iter()
.map(|(package, version)| (package.to_string(), version.to_string()))
.collect();
assert_eq!(declared, expected);
}
#[test]
fn an_unversioned_package_declaration_is_refused_naming_the_file() {
let tree = tempfile::tempdir().unwrap();
let file = tree.path().join("deps/bare.wit");
write(&file, "package murmur:bare;\n");
let err = wit_package_declarations(tree.path()).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert!(
err.to_string().contains(&file.display().to_string()),
"{err}"
);
}
#[test]
fn a_missing_directory_is_an_error() {
let tree = tempfile::tempdir().unwrap();
assert!(wit_package_declarations(&tree.path().join("absent")).is_err());
}
}