Skip to main content

standard_plugin_cli/
check.rs

1//! `standard-plugin check`: everything a host would reject, found before
2//! upload, plus warnings for what it would accept but probably should not.
3//!
4//! The manifest is parsed by `standard-plugin-manifest`, the same code the
5//! viewer's host uses, so `check` never passes a manifest a host refuses.
6
7use std::collections::BTreeSet;
8use std::fmt;
9
10use serde_json::Value;
11use standard_plugin_manifest::{
12    GrantSpec, MODULE_MAX_BYTES, Manifest, ManifestError, PluginKind, REASON_MAX_CHARS,
13    SurfaceAnchor, World,
14};
15
16use crate::component::{self, ComponentShape};
17use crate::world::{self, WorldShape};
18
19/// What `check` found.
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct Report {
22    pub errors: Vec<String>,
23    pub warnings: Vec<String>,
24    /// The parsed manifest, when it is valid.
25    pub manifest: Option<Manifest>,
26}
27
28impl Report {
29    pub fn is_ok(&self) -> bool {
30        self.errors.is_empty()
31    }
32
33    fn error(&mut self, message: impl Into<String>) {
34        self.errors.push(message.into());
35    }
36
37    fn warn(&mut self, message: impl Into<String>) {
38        self.warnings.push(message.into());
39    }
40}
41
42impl fmt::Display for Report {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        for error in &self.errors {
45            writeln!(f, "  error: {error}")?;
46        }
47        for warning in &self.warnings {
48            writeln!(f, "  warning: {warning}")?;
49        }
50        Ok(())
51    }
52}
53
54/// Grant kinds a UI plugin may hold that act on the account. A UI plugin
55/// that holds one and also listens to shared events runs that action in
56/// every viewer for every event.
57const EFFECT_KINDS: [&str; 4] = ["fetch", "values.write", "events.emit", "call"];
58
59fn grant_kind(grant: &str) -> &str {
60    grant.split_once(':').map_or(grant, |(kind, _)| kind)
61}
62
63/// Checks a manifest and, when given, the component it names.
64pub fn check(manifest_bytes: &[u8], component: Option<&[u8]>) -> Report {
65    let mut report = Report::default();
66    let raw: Value = match serde_json::from_slice(manifest_bytes) {
67        Ok(raw) => raw,
68        Err(error) => {
69            report.error(format!("standard-plugin.json: {error}"));
70            return report;
71        }
72    };
73    let world = raw
74        .get("kind")
75        .and_then(Value::as_str)
76        .and_then(PluginKind::parse)
77        .map_or(World::Ui, PluginKind::world);
78
79    // Every grant, not just the first bad one.
80    let mut reason_errors = false;
81    if let Some(grants) = raw.get("grants").and_then(Value::as_object) {
82        for (grant, reason) in grants {
83            match reason.as_str().map(|reason| reason.trim().chars().count()) {
84                Some(0) | None => {
85                    reason_errors = true;
86                    report.error(format!(
87                        "grant `{grant}` has no reason: add one sentence (at most \
88                         {REASON_MAX_CHARS} characters) saying why the plugin needs it"
89                    ));
90                }
91                Some(length) if length > REASON_MAX_CHARS => {
92                    reason_errors = true;
93                    report.error(format!(
94                        "grant `{grant}`: the reason is {length} characters; the install \
95                         screen shows at most {REASON_MAX_CHARS}"
96                    ));
97                }
98                Some(_) => {}
99            }
100            if let Err(error) = GrantSpec::classify(grant, world) {
101                report.error(error.to_string());
102            }
103        }
104    }
105
106    let manifest = match Manifest::parse(manifest_bytes) {
107        Ok(manifest) => manifest,
108        Err(ManifestError::Reason(_)) if reason_errors => return report,
109        Err(error) => {
110            report.error(error.to_string());
111            return report;
112        }
113    };
114    let kind = manifest.plugin_kind().unwrap_or(PluginKind::Ui);
115    let grants = manifest.grants();
116
117    for grant in manifest.grants.keys() {
118        let kind = grant_kind(grant);
119        if matches!(
120            kind,
121            "values.read"
122                | "values.write"
123                | "live.publish"
124                | "live.subscribe"
125                | "events.emit"
126                | "events.on"
127        ) && let Some((_, subject)) = grant.split_once(':')
128            && grants.is_own_namespace(subject)
129        {
130            report.warn(format!(
131                "grant `{grant}` covers the plugin's own namespace (`{}.*`), which needs no \
132                 grant",
133                manifest.id
134            ));
135        }
136    }
137
138    if kind == PluginKind::Ui {
139        if manifest.surfaces.is_empty() {
140            report.warn("a ui plugin with no surfaces shows nothing");
141        }
142        for surface in &manifest.surfaces {
143            if surface.height.is_some() && !surface.anchor.takes_height() {
144                report.warn(format!(
145                    "surface `{}`: `height` is ignored on anchor `{}` (the viewer sizes it)",
146                    surface.id,
147                    surface.anchor.as_str()
148                ));
149            }
150            // A machine or project row may start with no rows and ask for
151            // some once it has something to show; anything else would never
152            // show.
153            if surface.height == Some(0)
154                && !matches!(
155                    surface.anchor,
156                    SurfaceAnchor::MachineAfter
157                        | SurfaceAnchor::ProjectAfter
158                        | SurfaceAnchor::ProjectBefore
159                )
160            {
161                report.error(format!("surface `{}`: height 0 shows nothing", surface.id));
162            }
163            if surface.width.is_some() && !surface.anchor.takes_width() {
164                report.warn(format!(
165                    "surface `{}`: `width` is ignored on anchor `{}`",
166                    surface.id,
167                    surface.anchor.as_str()
168                ));
169            }
170        }
171        double_play(&manifest, &mut report);
172    }
173
174    if let Some(bytes) = component {
175        if bytes.len() as u64 > MODULE_MAX_BYTES {
176            report.error(format!(
177                "{} is {} bytes; a host loads at most {MODULE_MAX_BYTES}",
178                manifest.module,
179                bytes.len()
180            ));
181        }
182        match component::inspect(bytes) {
183            Ok(shape) => check_component(&manifest, kind, &shape, &mut report),
184            Err(error) => report.error(format!("{}: {error}", manifest.module)),
185        }
186    }
187    report.manifest = Some(manifest);
188    report
189}
190
191/// The double-play rule: a UI plugin runs once per open viewer, so one
192/// that reacts to shared events with an effect performs it once per
193/// viewer.
194fn double_play(manifest: &Manifest, report: &mut Report) {
195    let listens: Vec<&String> = manifest
196        .grants
197        .keys()
198        .filter(|grant| grant_kind(grant) == "events.on")
199        .collect();
200    let effects: Vec<&String> = manifest
201        .grants
202        .keys()
203        .filter(|grant| EFFECT_KINDS.contains(&grant_kind(grant)))
204        .collect();
205    if listens.is_empty() || effects.is_empty() {
206        return;
207    }
208    let list = |grants: &[&String]| {
209        grants
210            .iter()
211            .map(|grant| format!("`{grant}`"))
212            .collect::<Vec<_>>()
213            .join(", ")
214    };
215    report.warn(format!(
216        "double play: this ui plugin listens to shared events ({}) and can act on the \
217         account ({}); every open viewer runs it, so an effect in an event handler happens \
218         once per viewer. Guard effects with `view::is_driving()` and a claim, or move them \
219         to a companion daemon",
220        list(&listens),
221        list(&effects)
222    ));
223}
224
225fn check_component(
226    manifest: &Manifest,
227    kind: PluginKind,
228    shape: &ComponentShape,
229    report: &mut Report,
230) {
231    let world = kind.world();
232    let WorldShape { imports, exports } = world::shape(world);
233    let mut wasi = Vec::new();
234    for import in &shape.imports {
235        if imports.contains(import) {
236            continue;
237        }
238        if import.starts_with("wasi:") {
239            wasi.push(import.as_str());
240            continue;
241        }
242        report.error(format!(
243            "imports `{import}`, which the {} world does not offer: instantiation would fail",
244            world.wit_name()
245        ));
246    }
247    // `standardd` links WASI 0.2 for daemon plugins; a viewer never does.
248    if !wasi.is_empty() && world == World::Ui {
249        let list = wasi.join("`, `");
250        report.error(format!(
251            "imports WASI (`{list}`): a ui plugin cannot use WASI. Make the crate \
252             `#![no_std]` and keep the SDK's `std` and `wasi` features off"
253        ));
254    }
255    let exported: BTreeSet<&str> = shape.exports.iter().map(String::as_str).collect();
256    for export in &exports {
257        if !exported.contains(export.as_str()) {
258            report.error(format!(
259                "does not export `{export}`, which the {} world requires",
260                world.wit_name()
261            ));
262        }
263    }
264    for export in &shape.exports {
265        if !exports.contains(export) {
266            report.warn(format!(
267                "exports `{export}`, which no host calls in the {} world",
268                world.wit_name()
269            ));
270        }
271    }
272
273    let imported: BTreeSet<&str> = shape
274        .function_imports
275        .iter()
276        .filter_map(|import| {
277            import
278                .strip_prefix("standard:plugin/")
279                .and_then(|rest| rest.split('@').next())
280        })
281        .collect();
282    let grants = manifest.grants();
283    let holds = |kind: &str| {
284        manifest
285            .grants
286            .keys()
287            .any(|grant| grant_kind(grant) == kind)
288    };
289    let own_call = format!("call:{}", manifest.id);
290    // `machine.full` covers every program, path and host (the host checks it
291    // before the narrower grant), so it covers the daemon interfaces too.
292    let full = holds("machine.full");
293    let uncovered: [(&str, bool, String); 7] = [
294        ("account", holds("account.read"), "`account.read`".into()),
295        ("url", holds("url.open"), "a `url.open:<host>` grant".into()),
296        ("secrets", holds("secret"), "a `secret:<NAME>` grant".into()),
297        ("calls", grants.allows(&own_call), format!("`{own_call}`")),
298        (
299            "daemon-process",
300            full || holds("process.exec"),
301            "a `process.exec:<program>` grant (or `machine.full`)".into(),
302        ),
303        (
304            "daemon-watch",
305            full || holds("fs.read"),
306            "an `fs.read:<path>` grant (or `machine.full`)".into(),
307        ),
308        (
309            "daemon-panes",
310            holds("panes.read") || holds("panes.write"),
311            "`panes.read` or `panes.write`".into(),
312        ),
313    ];
314    for (interface, covered, needed) in uncovered {
315        if imported.contains(interface) && !covered {
316            report.warn(format!(
317                "uses the `{interface}` interface but no grant covers it: every call will \
318                 return GrantDenied until the manifest asks for {needed}"
319            ));
320        }
321    }
322    // WASI interfaces a daemon grant is used through, beside its own.
323    let through_wasi = |kind: &str| {
324        let prefix = match kind {
325            "fs.read" | "fs.write" => "wasi:filesystem/",
326            "fetch" => "wasi:http/",
327            _ => return false,
328        };
329        shape
330            .imports
331            .iter()
332            .any(|import| import.starts_with(prefix))
333    };
334    for grant in manifest.grants.keys() {
335        let Ok(spec) = GrantSpec::classify(grant, world) else {
336            continue;
337        };
338        if let Some(interface) = spec.interface
339            && !imported.contains(interface)
340            && !through_wasi(spec.kind)
341        {
342            report.warn(format!(
343                "grant `{grant}` is never used: the component does not import `{interface}`"
344            ));
345        }
346    }
347}