use std::io::{Cursor, Read, Seek};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use wasmparser::{Encoding, Parser, Payload};
use zip::ZipArchive;
use crate::{payload_shape::select_root_wasm_in_archive, 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
}
}
#[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> {
let wasm = read_root_wasm(artifact_bytes)?;
extract_wit_contracts(&wasm).ok().flatten()
}
fn read_root_wasm(artifact_bytes: &[u8]) -> Option<Vec<u8>> {
let mut archive = ZipArchive::new(Cursor::new(artifact_bytes)).ok()?;
read_root_wasm_from_archive(&mut archive)
}
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()
}
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 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());
}
}