Skip to main content

yapi_core/chest/
wasm.rs

1//! The capabilities a native extension uses, read from its WebAssembly
2//! component: the `yapi:capabilities` section that `yapi-extension-api`
3//! writes, and the WASI interfaces it imports.
4
5use std::collections::HashSet;
6
7use wasmparser::{Encoding, Parser, Payload};
8
9use super::{ChestError, invalid};
10use crate::extensions::CAPABILITIES;
11
12/// The custom section that lists the SDK features of a build.
13const SECTION: &str = "yapi:capabilities";
14/// The section's line that says the SDK wrote it.
15const MARKER: &str = "yapi-extension-api";
16
17/// A capability a component uses, and what shows it.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct Use {
20    /// One of [`CAPABILITIES`].
21    pub capability: &'static str,
22    /// What shows it, such as ``SDK feature `session` `` or
23    /// ``WASI import `wasi:filesystem/types@0.2.6` ``.
24    pub source: String,
25}
26
27/// What a component uses.
28#[derive(Clone, Debug, Default, PartialEq, Eq)]
29pub struct Capabilities {
30    /// Whether it carries a `yapi:capabilities` section. Without one it was
31    /// built without capability metadata, so only its WASI imports show
32    /// what it uses.
33    pub metadata: bool,
34    /// Each capability it uses, once, with the first thing that shows it.
35    /// A build with the SDK's `all` feature lists `all` alone from the
36    /// section.
37    pub used: Vec<Use>,
38    /// The WASI network interfaces it imports, which yapi does not provide:
39    /// it links no `wasi:http`, and refuses every `wasi:sockets` call. An
40    /// extension reaches the network through the SDK's `fetch`.
41    pub unsupported: Vec<String>,
42    /// The lines of its section that name no capability yapi knows, each
43    /// once, such as one a newer SDK writes.
44    pub unknown: Vec<String>,
45}
46
47impl Capabilities {
48    fn add(&mut self, capability: &'static str, source: String) {
49        if !self.used.iter().any(|used| used.capability == capability) {
50            self.used.push(Use { capability, source });
51        }
52    }
53}
54
55/// The capability a section line names.
56fn feature(line: &str) -> Option<&'static str> {
57    CAPABILITIES.into_iter().find(|name| *name == line)
58}
59
60/// The capability a WASI import needs. `wasi:cli/environment` counts for
61/// none: every Rust component imports it, and yapi gives WASI no
62/// environment variables, so an extension reads them through the SDK.
63fn import(name: &str) -> Option<&'static str> {
64    name.starts_with("wasi:filesystem/").then_some("fs")
65}
66
67/// Whether yapi leaves WASI import `name` out, as [`Capabilities::unsupported`]
68/// describes.
69fn unsupported(name: &str) -> bool {
70    name.starts_with("wasi:sockets/") || name.starts_with("wasi:http/")
71}
72
73/// What the component `bytes` uses: the features its `yapi:capabilities`
74/// section lists, in any of its modules, then the WASI interfaces the
75/// component itself imports. `Err` for bytes that are not a WebAssembly
76/// component.
77pub fn capabilities(bytes: &[u8]) -> Result<Capabilities, ChestError> {
78    let mut found = Capabilities::default();
79    let mut features = Vec::new();
80    let mut imports = Vec::new();
81    let mut unknown = HashSet::new();
82    let mut depth = 0usize;
83    for payload in Parser::new(0).parse_all(bytes) {
84        let payload =
85            payload.map_err(|err| invalid(format!("not a WebAssembly component: {err}")))?;
86        match payload {
87            Payload::Version { encoding, .. } => {
88                if depth == 0 && encoding != Encoding::Component {
89                    return Err(invalid(
90                        "a WebAssembly module, not a component: build it for wasm32-wasip2",
91                    ));
92                }
93                depth += 1;
94            }
95            Payload::End(_) => depth = depth.saturating_sub(1),
96            Payload::CustomSection(section) if section.name() == SECTION => {
97                found.metadata = true;
98                let text = String::from_utf8_lossy(section.data());
99                for line in text
100                    .lines()
101                    .filter(|line| !line.is_empty() && *line != MARKER)
102                {
103                    match feature(line) {
104                        Some(capability) => features.push(capability),
105                        None if unknown.insert(line.to_owned()) => {
106                            found.unknown.push(line.to_owned());
107                        }
108                        None => {}
109                    }
110                }
111            }
112            // A nested component's imports are met inside the component.
113            Payload::ComponentImportSection(reader) if depth == 1 => {
114                for entry in reader {
115                    let entry = entry
116                        .map_err(|err| invalid(format!("not a WebAssembly component: {err}")))?;
117                    imports.push(entry.name.name.to_owned());
118                }
119            }
120            _ => {}
121        }
122    }
123    // The SDK's `all` feature turns on the others.
124    if features.contains(&"all") {
125        features = vec!["all"];
126    }
127    for capability in features {
128        found.add(capability, format!("SDK feature `{capability}`"));
129    }
130    for name in imports {
131        if let Some(capability) = import(&name) {
132            found.add(capability, format!("WASI import `{name}`"));
133        } else if unsupported(&name) {
134            found.unsupported.push(name);
135        }
136    }
137    Ok(found)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn fixture(name: &str) -> Vec<u8> {
145        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../yapi-ext/tests/fixtures");
146        std::fs::read(format!("{dir}/{name}.wasm")).unwrap()
147    }
148
149    /// A component section: id, LEB128 size, contents.
150    fn section(id: u8, contents: &[u8]) -> Vec<u8> {
151        let (mut bytes, mut size) = (vec![id], contents.len());
152        while size >= 0x80 {
153            bytes.push((size as u8) | 0x80);
154            size >>= 7;
155        }
156        bytes.push(size as u8);
157        [&bytes[..], contents].concat()
158    }
159
160    fn name(text: &str) -> Vec<u8> {
161        [&[text.len() as u8], text.as_bytes()].concat()
162    }
163
164    /// An empty component that imports each of `interfaces` as an instance
165    /// with no exports.
166    fn importing(interfaces: &[&str]) -> Vec<u8> {
167        let mut imports = vec![interfaces.len() as u8];
168        for interface in interfaces {
169            imports.extend([&[0x00][..], &name(interface), &[0x05, 0x00]].concat());
170        }
171        let types = section(0x07, &[0x01, 0x42, 0x00]);
172        [&EMPTY[..], &types, &section(0x0a, &imports)].concat()
173    }
174
175    const EMPTY: [u8; 8] = *b"\0asm\x0d\x00\x01\x00";
176
177    /// yapi-extension-api cannot depend on yapi-core, so its capability
178    /// features are checked against [`CAPABILITIES`] here: one each, and
179    /// `all` turns on the others.
180    #[test]
181    fn sdk_features_match() {
182        let manifest = concat!(
183            env!("CARGO_MANIFEST_DIR"),
184            "/../../guest/yapi-extension-api/Cargo.toml"
185        );
186        let manifest: toml_edit::DocumentMut =
187            std::fs::read_to_string(manifest).unwrap().parse().unwrap();
188        let features = manifest["features"].as_table().unwrap();
189        let mut names: Vec<&str> = features
190            .iter()
191            .map(|(name, _)| name)
192            .filter(|name| !["default", "widgets"].contains(name))
193            .collect();
194        let mut expected = CAPABILITIES.to_vec();
195        names.sort_unstable();
196        expected.sort_unstable();
197        assert_eq!(names, expected);
198        let all: Vec<&str> = features["all"]
199            .as_array()
200            .unwrap()
201            .iter()
202            .filter_map(|value| value.as_str())
203            .collect();
204        assert_eq!(all, CAPABILITIES[..CAPABILITIES.len() - 1]);
205    }
206
207    fn used(found: &Capabilities) -> Vec<(&str, &str)> {
208        let pairs = found.used.iter();
209        pairs
210            .map(|used| (used.capability, used.source.as_str()))
211            .collect()
212    }
213
214    #[test]
215    fn capabilities_from_component() {
216        let hello = capabilities(&fixture("hello")).unwrap();
217        assert!(hello.metadata && hello.unknown.is_empty());
218        assert_eq!(used(&hello), [("session", "SDK feature `session`")]);
219        let probe = capabilities(&fixture("capability-probe")).unwrap();
220        assert_eq!(used(&probe), [("all", "SDK feature `all`")]);
221        let todo = capabilities(&fixture("todo")).unwrap();
222        assert!(todo.metadata);
223        assert_eq!(used(&todo), []);
224
225        let wasi = importing(&[
226            "wasi:cli/environment@0.2.6",
227            "wasi:filesystem/types@0.2.6",
228            "wasi:http/outgoing-handler@0.2.6",
229        ]);
230        let wasi = capabilities(&wasi).unwrap();
231        assert!(!wasi.metadata);
232        assert_eq!(
233            used(&wasi),
234            [("fs", "WASI import `wasi:filesystem/types@0.2.6`")]
235        );
236        assert_eq!(wasi.unsupported, ["wasi:http/outgoing-handler@0.2.6"]);
237        let lines = b"yapi-extension-api\nnet\nexec:git\nfs\nexec:git\n";
238        let custom = section(0x00, &[&name(SECTION)[..], lines].concat());
239        let declared =
240            capabilities(&[&importing(&["wasi:sockets/tcp@0.2.6"])[..], &custom].concat());
241        let declared = declared.unwrap();
242        assert!(declared.metadata);
243        assert_eq!(
244            used(&declared),
245            [("net", "SDK feature `net`"), ("fs", "SDK feature `fs`")]
246        );
247        assert_eq!(declared.unsupported, ["wasi:sockets/tcp@0.2.6"]);
248        assert_eq!(declared.unknown, ["exec:git"]);
249        // 100,000 unknown lines, each given twice, are kept once each, in
250        // order, in linear time.
251        let lines: String = (0..100_000).map(|n| format!("x{n}\nx{n}\n")).collect();
252        let custom = section(0x00, &[&name(SECTION)[..], lines.as_bytes()].concat());
253        let many = capabilities(&[&EMPTY[..], &custom].concat()).unwrap();
254        assert_eq!(many.unknown.len(), 100_000);
255        assert_eq!(many.unknown[..2], ["x0", "x1"]);
256
257        assert_eq!(capabilities(&EMPTY).unwrap(), Capabilities::default());
258        let module = capabilities(b"\0asm\x01\x00\x00\x00").unwrap_err();
259        assert!(module.to_string().contains("not a component"), "{module}");
260        assert!(capabilities(b"plain text").is_err());
261    }
262}