1use 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct Report {
22 pub errors: Vec<String>,
23 pub warnings: Vec<String>,
24 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
54const 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
63pub 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 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 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
191fn 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 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 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 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}