use std::collections::BTreeSet;
use std::fmt;
use serde_json::Value;
use standard_plugin_manifest::{
GrantSpec, MODULE_MAX_BYTES, Manifest, ManifestError, PluginKind, REASON_MAX_CHARS,
SurfaceAnchor, World,
};
use crate::component::{self, ComponentShape};
use crate::world::{self, WorldShape};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Report {
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub manifest: Option<Manifest>,
}
impl Report {
pub fn is_ok(&self) -> bool {
self.errors.is_empty()
}
fn error(&mut self, message: impl Into<String>) {
self.errors.push(message.into());
}
fn warn(&mut self, message: impl Into<String>) {
self.warnings.push(message.into());
}
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for error in &self.errors {
writeln!(f, " error: {error}")?;
}
for warning in &self.warnings {
writeln!(f, " warning: {warning}")?;
}
Ok(())
}
}
const EFFECT_KINDS: [&str; 4] = ["fetch", "values.write", "events.emit", "call"];
fn grant_kind(grant: &str) -> &str {
grant.split_once(':').map_or(grant, |(kind, _)| kind)
}
pub fn check(manifest_bytes: &[u8], component: Option<&[u8]>) -> Report {
let mut report = Report::default();
let raw: Value = match serde_json::from_slice(manifest_bytes) {
Ok(raw) => raw,
Err(error) => {
report.error(format!("standard-plugin.json: {error}"));
return report;
}
};
let world = raw
.get("kind")
.and_then(Value::as_str)
.and_then(PluginKind::parse)
.map_or(World::Ui, PluginKind::world);
let mut reason_errors = false;
if let Some(grants) = raw.get("grants").and_then(Value::as_object) {
for (grant, reason) in grants {
match reason.as_str().map(|reason| reason.trim().chars().count()) {
Some(0) | None => {
reason_errors = true;
report.error(format!(
"grant `{grant}` has no reason: add one sentence (at most \
{REASON_MAX_CHARS} characters) saying why the plugin needs it"
));
}
Some(length) if length > REASON_MAX_CHARS => {
reason_errors = true;
report.error(format!(
"grant `{grant}`: the reason is {length} characters; the install \
screen shows at most {REASON_MAX_CHARS}"
));
}
Some(_) => {}
}
if let Err(error) = GrantSpec::classify(grant, world) {
report.error(error.to_string());
}
}
}
let manifest = match Manifest::parse(manifest_bytes) {
Ok(manifest) => manifest,
Err(ManifestError::Reason(_)) if reason_errors => return report,
Err(error) => {
report.error(error.to_string());
return report;
}
};
let kind = manifest.plugin_kind().unwrap_or(PluginKind::Ui);
let grants = manifest.grants();
for grant in manifest.grants.keys() {
let kind = grant_kind(grant);
if matches!(
kind,
"values.read"
| "values.write"
| "live.publish"
| "live.subscribe"
| "events.emit"
| "events.on"
) && let Some((_, subject)) = grant.split_once(':')
&& grants.is_own_namespace(subject)
{
report.warn(format!(
"grant `{grant}` covers the plugin's own namespace (`{}.*`), which needs no \
grant",
manifest.id
));
}
}
if kind == PluginKind::Ui {
if manifest.surfaces.is_empty() {
report.warn("a ui plugin with no surfaces shows nothing");
}
for surface in &manifest.surfaces {
if surface.height.is_some() && !surface.anchor.takes_height() {
report.warn(format!(
"surface `{}`: `height` is ignored on anchor `{}` (the viewer sizes it)",
surface.id,
surface.anchor.as_str()
));
}
if surface.height == Some(0)
&& !matches!(
surface.anchor,
SurfaceAnchor::MachineAfter
| SurfaceAnchor::ProjectAfter
| SurfaceAnchor::ProjectBefore
)
{
report.error(format!("surface `{}`: height 0 shows nothing", surface.id));
}
if surface.width.is_some() && !surface.anchor.takes_width() {
report.warn(format!(
"surface `{}`: `width` is ignored on anchor `{}`",
surface.id,
surface.anchor.as_str()
));
}
}
double_play(&manifest, &mut report);
}
if let Some(bytes) = component {
if bytes.len() as u64 > MODULE_MAX_BYTES {
report.error(format!(
"{} is {} bytes; a host loads at most {MODULE_MAX_BYTES}",
manifest.module,
bytes.len()
));
}
match component::inspect(bytes) {
Ok(shape) => check_component(&manifest, kind, &shape, &mut report),
Err(error) => report.error(format!("{}: {error}", manifest.module)),
}
}
report.manifest = Some(manifest);
report
}
fn double_play(manifest: &Manifest, report: &mut Report) {
let listens: Vec<&String> = manifest
.grants
.keys()
.filter(|grant| grant_kind(grant) == "events.on")
.collect();
let effects: Vec<&String> = manifest
.grants
.keys()
.filter(|grant| EFFECT_KINDS.contains(&grant_kind(grant)))
.collect();
if listens.is_empty() || effects.is_empty() {
return;
}
let list = |grants: &[&String]| {
grants
.iter()
.map(|grant| format!("`{grant}`"))
.collect::<Vec<_>>()
.join(", ")
};
report.warn(format!(
"double play: this ui plugin listens to shared events ({}) and can act on the \
account ({}); every open viewer runs it, so an effect in an event handler happens \
once per viewer. Guard effects with `view::is_driving()` and a claim, or move them \
to a companion daemon",
list(&listens),
list(&effects)
));
}
fn check_component(
manifest: &Manifest,
kind: PluginKind,
shape: &ComponentShape,
report: &mut Report,
) {
let world = kind.world();
let WorldShape { imports, exports } = world::shape(world);
let mut wasi = Vec::new();
for import in &shape.imports {
if imports.contains(import) {
continue;
}
if import.starts_with("wasi:") {
wasi.push(import.as_str());
continue;
}
report.error(format!(
"imports `{import}`, which the {} world does not offer: instantiation would fail",
world.wit_name()
));
}
if !wasi.is_empty() && world == World::Ui {
let list = wasi.join("`, `");
report.error(format!(
"imports WASI (`{list}`): a ui plugin cannot use WASI. Make the crate \
`#![no_std]` and keep the SDK's `std` and `wasi` features off"
));
}
let exported: BTreeSet<&str> = shape.exports.iter().map(String::as_str).collect();
for export in &exports {
if !exported.contains(export.as_str()) {
report.error(format!(
"does not export `{export}`, which the {} world requires",
world.wit_name()
));
}
}
for export in &shape.exports {
if !exports.contains(export) {
report.warn(format!(
"exports `{export}`, which no host calls in the {} world",
world.wit_name()
));
}
}
let imported: BTreeSet<&str> = shape
.function_imports
.iter()
.filter_map(|import| {
import
.strip_prefix("standard:plugin/")
.and_then(|rest| rest.split('@').next())
})
.collect();
let grants = manifest.grants();
let holds = |kind: &str| {
manifest
.grants
.keys()
.any(|grant| grant_kind(grant) == kind)
};
let own_call = format!("call:{}", manifest.id);
let full = holds("machine.full");
let uncovered: [(&str, bool, String); 7] = [
("account", holds("account.read"), "`account.read`".into()),
("url", holds("url.open"), "a `url.open:<host>` grant".into()),
("secrets", holds("secret"), "a `secret:<NAME>` grant".into()),
("calls", grants.allows(&own_call), format!("`{own_call}`")),
(
"daemon-process",
full || holds("process.exec"),
"a `process.exec:<program>` grant (or `machine.full`)".into(),
),
(
"daemon-watch",
full || holds("fs.read"),
"an `fs.read:<path>` grant (or `machine.full`)".into(),
),
(
"daemon-panes",
holds("panes.read") || holds("panes.write"),
"`panes.read` or `panes.write`".into(),
),
];
for (interface, covered, needed) in uncovered {
if imported.contains(interface) && !covered {
report.warn(format!(
"uses the `{interface}` interface but no grant covers it: every call will \
return GrantDenied until the manifest asks for {needed}"
));
}
}
let through_wasi = |kind: &str| {
let prefix = match kind {
"fs.read" | "fs.write" => "wasi:filesystem/",
"fetch" => "wasi:http/",
_ => return false,
};
shape
.imports
.iter()
.any(|import| import.starts_with(prefix))
};
for grant in manifest.grants.keys() {
let Ok(spec) = GrantSpec::classify(grant, world) else {
continue;
};
if let Some(interface) = spec.interface
&& !imported.contains(interface)
&& !through_wasi(spec.kind)
{
report.warn(format!(
"grant `{grant}` is never used: the component does not import `{interface}`"
));
}
}
}