standard-plugin-cli 0.1.0

standard-plugin: scaffold, build, check and pack Standard Code plugins
Documentation
//! `standard-plugin check`: everything a host would reject, found before
//! upload, plus warnings for what it would accept but probably should not.
//!
//! The manifest is parsed by `standard-plugin-manifest`, the same code the
//! viewer's host uses, so `check` never passes a manifest a host refuses.

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};

/// What `check` found.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Report {
    pub errors: Vec<String>,
    pub warnings: Vec<String>,
    /// The parsed manifest, when it is valid.
    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(())
    }
}

/// Grant kinds a UI plugin may hold that act on the account. A UI plugin
/// that holds one and also listens to shared events runs that action in
/// every viewer for every event.
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)
}

/// Checks a manifest and, when given, the component it names.
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);

    // Every grant, not just the first bad one.
    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()
                ));
            }
            // A machine or project row may start with no rows and ask for
            // some once it has something to show; anything else would never
            // show.
            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
}

/// The double-play rule: a UI plugin runs once per open viewer, so one
/// that reacts to shared events with an effect performs it once per
/// viewer.
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()
        ));
    }
    // `standardd` links WASI 0.2 for daemon plugins; a viewer never does.
    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);
    // `machine.full` covers every program, path and host (the host checks it
    // before the narrower grant), so it covers the daemon interfaces too.
    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}"
            ));
        }
    }
    // WASI interfaces a daemon grant is used through, beside its own.
    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}`"
            ));
        }
    }
}