feat: provide v2 Plugin Component contracts and offline validation

Validate exact Component interfaces and static Tool/DCDL schemas without guest execution. Share canonical archive packing and independent package/artifact verification; build real pure and HTTPS PDK fixtures and retire v1/Service authoring stubs.
This commit is contained in:
2026-10-28 13:31:47 +09:00
parent 06883c51b7
commit 2d195f4058
40 changed files with 3056 additions and 1662 deletions
+6
View File
@@ -15,11 +15,17 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_ignored = "0.1.14"
sha2 = "0.10"
crc32fast = "1"
wasmparser = "0.244.0"
wit-parser = "0.244.0"
wit-component = "0.244.0"
jsonschema = { version = "0.33.0", default-features = false }
secrets = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
wit-component = { version = "0.244.0", features = ["dummy-module"] }
serial_test = "3.4.0"
tempfile = { workspace = true }
File diff suppressed because it is too large Load Diff
+572
View File
@@ -0,0 +1,572 @@
//! Static validation of the exact, supported Plugin Component ABI.
//!
//! Component metadata is inferred from validated bytes, never trusted from a
//! producer's custom sections. This module does not instantiate guest code.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use wit_component::{DecodedWasm, WitPrinter};
use wit_parser::{InterfaceId, Resolve, WorldId, WorldItem, WorldKey};
const PLUGIN_WIT: &str = include_str!("../../../../resources/plugin/wit/yoi-plugin-v2.wit");
const HOST_WIT: &str =
include_str!("../../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentMetadata {
pub world: String,
pub imports: Vec<String>,
pub exports: Vec<String>,
}
/// Validates an executable Component against the embedded exact-version world.
///
/// `required_imports` is a set (duplicates are rejected); it must equal both the
/// selected world's imports and the actual Component imports. Returned names are
/// fully qualified and sorted. The world name comes from the supported contract,
/// not a producer-supplied custom section: executable Components do not encode
/// the original WIT world name.
pub fn validate_component(
bytes: &[u8],
world: &str,
required_imports: &[String],
) -> Result<ComponentMetadata, String> {
let world_name = match world {
"yoi:plugin/tool-plugin@2.0.0" => "tool-plugin",
"yoi:plugin/https-tool-plugin@2.0.0" => "https-tool-plugin",
"yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin",
"yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin",
_ => return Err(format!("unsupported Component world `{world}`")),
};
if !wasmparser::Parser::is_component(bytes) {
return Err("expected a WebAssembly Component, not a core module".into());
}
wasmparser::Validator::new()
.validate_all(bytes)
.map_err(|error| format!("invalid WebAssembly Component: {error}"))?;
let (actual, actual_world) = match wit_component::decode(bytes)
.map_err(|error| format!("cannot decode Component interface: {error:#}"))?
{
DecodedWasm::Component(resolve, world) => (resolve, world),
DecodedWasm::WitPackage(..) => {
return Err("expected an executable Component, not a WIT type package".into());
}
};
let (expected, expected_world) = contract(world_name)?;
let expected_imports = surface(&expected, expected_world, true)?;
let expected_exports = surface(&expected, expected_world, false)?;
let actual_imports = surface(&actual, actual_world, true)?;
let actual_exports = surface(&actual, actual_world, false)?;
let required: BTreeSet<_> = required_imports.iter().cloned().collect();
if required.len() != required_imports.len() {
return Err("duplicate required Component import in manifest".into());
}
let imports: Vec<_> = expected_imports.keys().cloned().collect();
let exports: Vec<_> = expected_exports.keys().cloned().collect();
if required != expected_imports.keys().cloned().collect() {
return Err(format!(
"manifest required_imports do not match world `{world}`: expected {imports:?}"
));
}
if actual_imports.keys().ne(expected_imports.keys()) {
return Err(format!(
"Component imports do not match world `{world}`: expected {imports:?}, found {:?}",
actual_imports.keys().collect::<Vec<_>>()
));
}
if actual_exports.keys().ne(expected_exports.keys()) {
return Err(format!(
"Component exports do not match world `{world}`: expected {exports:?}, found {:?}",
actual_exports.keys().collect::<Vec<_>>()
));
}
// Compare all decoded interfaces, including those owning referenced types,
// not only function names or the world surface. Printing preserves the
// qualified owner of type aliases and resource identity, borrow/own, field
// names/order, and nested signatures. Resolve::merge is not an ABI check.
let expected_interfaces = named_interfaces(&expected)?;
for (name, id) in named_interfaces(&actual)? {
let expected_id = expected_interfaces
.get(&name)
.ok_or_else(|| format!("unsupported Component interface `{name}`"))?;
if interface_text(&actual, id)? != interface_text(&expected, *expected_id)? {
return Err(format!("Component interface `{name}` has an incorrect ABI"));
}
}
Ok(ComponentMetadata {
world: world.to_string(),
imports,
exports,
})
}
fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> {
let mut resolve = Resolve::default();
resolve
.push_str("yoi-host-v2.wit", HOST_WIT)
.map_err(|error| format!("invalid embedded Host WIT: {error:#}"))?;
let package = resolve
.push_str("yoi-plugin-v2.wit", PLUGIN_WIT)
.map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?;
let world = resolve
.select_world(&[package], Some(world_name))
.map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?;
Ok((resolve, world))
}
fn surface(
resolve: &Resolve,
world: WorldId,
imports: bool,
) -> Result<BTreeMap<String, InterfaceId>, String> {
let items = if imports {
&resolve.worlds[world].imports
} else {
&resolve.worlds[world].exports
};
let mut names = BTreeMap::new();
for (key, item) in items {
let (WorldKey::Interface(key_id), WorldItem::Interface { id, .. }) = (key, item) else {
return Err(
"Component world contains an unsupported bare function/type or named interface"
.into(),
);
};
if key_id != id {
return Err("Component interface identity does not match its world key".into());
}
let name = resolve
.id_of(*id)
.ok_or_else(|| "Component interface has no exact package identity".to_string())?;
if names.insert(name.clone(), *id).is_some() {
return Err(format!("duplicate Component interface `{name}`"));
}
}
Ok(names)
}
fn named_interfaces(resolve: &Resolve) -> Result<BTreeMap<String, InterfaceId>, String> {
let mut names = BTreeMap::new();
for (id, _) in resolve.interfaces.iter() {
let name = resolve
.id_of(id)
.ok_or_else(|| "Component interface has no exact package identity".to_string())?;
if names.insert(name.clone(), id).is_some() {
return Err(format!("duplicate Component interface `{name}`"));
}
}
Ok(names)
}
fn interface_text(resolve: &Resolve, id: InterfaceId) -> Result<String, String> {
// Declaration order is not ABI; record fields, variant cases, parameters,
// and results remain ordered. Sorting maps also canonicalizes use aliases
// and resource methods without changing their nominal owners.
let mut resolve = resolve.clone();
resolve.interfaces[id].types.sort_keys();
resolve.interfaces[id].functions.sort_keys();
let mut printer = WitPrinter::default();
printer.emit_docs(false);
printer
.print_interface(&resolve, id)
.map_err(|error| format!("cannot inspect Component ABI: {error:#}"))?;
Ok(printer.output.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use wit_component::{ComponentEncoder, StringEncoding, dummy_module, embed_component_metadata};
use wit_parser::ManglingAndAbi;
const PURE: &str = "yoi:plugin/tool-plugin@2.0.0";
const HTTPS: &str = "yoi:plugin/https-tool-plugin@2.0.0";
const LOGGED: &str = "yoi:plugin/logged-tool-plugin@2.0.0";
const HTTPS_LOGGED: &str = "yoi:plugin/https-logged-tool-plugin@2.0.0";
fn fixture(host: &str, plugin: &str, world_name: &str) -> Vec<u8> {
let mut resolve = Resolve::default();
resolve.push_str("host.wit", host).unwrap();
let package = resolve.push_str("plugin.wit", plugin).unwrap();
let world = resolve.select_world(&[package], Some(world_name)).unwrap();
encode(&resolve, world)
}
fn encode(resolve: &Resolve, world: WorldId) -> Vec<u8> {
let mut module = dummy_module(resolve, world, ManglingAndAbi::Standard32);
embed_component_metadata(&mut module, resolve, world, StringEncoding::UTF8).unwrap();
ComponentEncoder::default()
.module(&module)
.unwrap()
.validate(true)
.encode()
.unwrap()
}
fn https_imports() -> Vec<String> {
[
"yoi:host/connections@2.0.0",
"yoi:host/errors@2.0.0",
"yoi:host/http@2.0.0",
]
.map(str::to_string)
.into()
}
fn logged_imports(https: bool) -> Vec<String> {
let mut imports = if https {
https_imports()
} else {
vec!["yoi:host/errors@2.0.0".into()]
};
imports.push("yoi:host/logging@2.0.0".into());
imports
}
#[test]
fn exact_world_components_return_sorted_metadata_without_running_guest() {
for (name, world, imports) in [
("tool-plugin", PURE, vec![]),
("https-tool-plugin", HTTPS, https_imports()),
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let component = fixture(HOST_WIT, PLUGIN_WIT, name);
assert_eq!(
validate_component(&component, world, &imports).unwrap(),
ComponentMetadata {
world: world.into(),
imports,
exports: vec!["yoi:plugin/tool@2.0.0".into()],
}
);
}
}
#[test]
fn logging_worlds_require_exact_manifest_and_actual_import_sets() {
for (name, world, imports) in [
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let component = fixture(HOST_WIT, PLUGIN_WIT, name);
let mut missing = imports.clone();
missing.retain(|name| !name.contains("/logging@"));
assert!(
validate_component(&component, world, &missing)
.unwrap_err()
.contains("manifest required_imports")
);
let plugin = PLUGIN_WIT.replace("import yoi:host/logging@2.0.0;", "");
let component = fixture(HOST_WIT, &plugin, name);
assert!(
validate_component(&component, world, &imports)
.unwrap_err()
.contains("Component imports")
);
}
}
#[test]
fn logging_world_and_interface_versions_are_exact() {
let logging = HOST_WIT
.split_once("interface logging {")
.unwrap()
.1
.replace(
"use errors.{host-error};",
"use yoi:host/errors@2.0.0.{host-error};",
);
for (name, world, imports) in [
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let supported = fixture(HOST_WIT, PLUGIN_WIT, name);
for version in ["1.0.0", "2.0.1", "9.0.0"] {
let unsupported_world = world.replace("@2.0.0", &format!("@{version}"));
assert!(
validate_component(&supported, &unsupported_world, &imports)
.unwrap_err()
.contains("unsupported Component world")
);
let host = format!(
"{HOST_WIT}\npackage yoi:host@{version} {{ interface logging {{ {logging} }}"
);
let plugin = PLUGIN_WIT.replace("logging@2.0.0", &format!("logging@{version}"));
let component = fixture(&host, &plugin, name);
assert!(
validate_component(&component, world, &imports)
.unwrap_err()
.contains("Component imports")
);
}
}
}
#[test]
fn typed_logging_nested_payload_and_signature_changes_are_rejected() {
for (from, to) in [
(
"trace, debug, info, warn, error",
"debug, trace, info, warn, error",
),
("number(f64)", "number(f32)"),
("signed(s64)", "signed(u64)"),
("value: scalar", "value: string"),
("fields: list<field>", "fields: option<field>"),
("event-code: string", "event-name: string"),
(
"message: string,\n fields:",
"message: string, extra: bool,\n fields:",
),
("log: func(input: entry)", "log: func(input: string)"),
(
"log: func(input: entry) -> result<_, host-error>",
"log: func(input: entry) -> result<string, host-error>",
),
("log: func", "extra: func(); log: func"),
] {
assert!(HOST_WIT.contains(from));
let host = HOST_WIT.replace(from, to);
for (name, world, imports) in [
("logged-tool-plugin", LOGGED, logged_imports(false)),
(
"https-logged-tool-plugin",
HTTPS_LOGGED,
logged_imports(true),
),
] {
let component = fixture(&host, PLUGIN_WIT, name);
let error = validate_component(&component, world, &imports).unwrap_err();
assert!(
error.contains("yoi:host/logging@2.0.0") && error.contains("incorrect ABI"),
"mutation: {from} -> {to}: {error}"
);
}
}
}
#[test]
fn validated_metadata_has_serializable_public_fields() {
let component = fixture(HOST_WIT, PLUGIN_WIT, "logged-tool-plugin");
let metadata = validate_component(&component, LOGGED, &logged_imports(false)).unwrap();
let json = serde_json::to_value(&metadata).unwrap();
assert_eq!(
json,
serde_json::json!({
"world": LOGGED,
"imports": ["yoi:host/errors@2.0.0", "yoi:host/logging@2.0.0"],
"exports": ["yoi:plugin/tool@2.0.0"],
})
);
assert_eq!(
serde_json::from_value::<ComponentMetadata>(json).unwrap(),
metadata
);
}
#[test]
fn v1_unknown_and_wrong_manifest_worlds_are_rejected() {
let component = fixture(HOST_WIT, PLUGIN_WIT, "tool-plugin");
for world in [
"yoi:plugin/tool-plugin@1.0.0",
"yoi:plugin/tool-plugin@2.0.1",
] {
assert!(
validate_component(&component, world, &[])
.unwrap_err()
.contains("unsupported Component world")
);
}
assert!(
validate_component(&component, HTTPS, &https_imports())
.unwrap_err()
.contains("Component imports")
);
for version in ["1.0.0", "9.0.0"] {
let changed = PLUGIN_WIT.replace("2.0.0", version);
let changed_host = HOST_WIT.replace("2.0.0", version);
let component = fixture(&changed_host, &changed, "tool-plugin");
assert!(
validate_component(&component, PURE, &[])
.unwrap_err()
.contains("Component exports")
);
}
}
#[test]
fn wrong_host_interface_versions_are_not_semver_compatible() {
for version in ["1.0.0", "2.0.1", "9.0.0"] {
let host = HOST_WIT.replace("@2.0.0", &format!("@{version}"));
let plugin = PLUGIN_WIT
.replace("errors@2.0.0", &format!("errors@{version}"))
.replace("connections@2.0.0", &format!("connections@{version}"))
.replace("http@2.0.0", &format!("http@{version}"))
.replace("logging@2.0.0", &format!("logging@{version}"));
let component = fixture(&host, &plugin, "https-tool-plugin");
assert!(
validate_component(&component, HTTPS, &https_imports())
.unwrap_err()
.contains("Component imports")
);
}
}
#[test]
fn manifest_import_set_must_equal_actual_and_supported_world() {
let component = fixture(HOST_WIT, PLUGIN_WIT, "https-tool-plugin");
let mut reordered = https_imports();
reordered.reverse();
assert!(validate_component(&component, HTTPS, &reordered).is_ok());
let mut duplicate = https_imports();
duplicate.push(duplicate[0].clone());
for imports in [
vec![],
vec!["wasi:filesystem/types@0.2.0".into()],
duplicate,
] {
assert!(validate_component(&component, HTTPS, &imports).is_err());
}
}
#[test]
fn unadvertised_imports_and_extra_exports_are_rejected() {
for (from, to) in [
("export tool;", ""),
(
"world tool-plugin {",
"world tool-plugin { import surprise: func();",
),
(
"world tool-plugin {",
"world tool-plugin { export surprise: func();",
),
(
"world tool-plugin {",
"world tool-plugin { import yoi:host/logging@2.0.0;",
),
] {
let plugin = PLUGIN_WIT.replace(from, to);
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
assert!(validate_component(&component, PURE, &[]).is_err());
}
let plugin = PLUGIN_WIT.replace(
"world tool-plugin {",
"world tool-plugin { import wasi:unpublished/io@0.2.0;",
);
let host = format!(
"{HOST_WIT}\npackage wasi:unpublished@0.2.0 {{ interface io {{ read: func(); }} }}"
);
let component = fixture(&host, &plugin, "tool-plugin");
assert!(validate_component(&component, PURE, &[]).is_err());
}
#[test]
fn same_interface_name_with_changed_nested_signature_is_rejected() {
for (from, to) in [
("remaining-ms: u32", "remaining-ms: u64"),
("remaining-ms: u32", "time-left: u32"),
("content: option<string>", "content: list<string>"),
("summary: string", "summary: string, extra: bool"),
("input: invocation", "input: output"),
("result<output, tool-error>", "result<tool-error, output>"),
("invoke: func", "extra: func(); invoke: func"),
] {
assert!(PLUGIN_WIT.contains(from));
let plugin = PLUGIN_WIT.replace(from, to);
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
assert!(
validate_component(&component, PURE, &[])
.unwrap_err()
.contains("incorrect ABI"),
"mutation: {from} -> {to}"
);
}
}
#[test]
fn host_resource_identity_ownership_and_nested_records_are_exact() {
for (from, to) in [
("borrow<connection>", "own<connection>"),
("use connections.{connection};", "resource connection;"),
("status: u16", "status: u32"),
("value: string", "value: list<u8>"),
(
"resource connection;",
"resource connection; resource other;",
),
("target: borrow<connection>", "target: borrow<other>"),
("diagnostic-id: string", "diagnostic-id: u64"),
] {
let mut host = HOST_WIT.replace(from, to);
if to == "target: borrow<other>" {
host = host.replace("interface http {", "interface http { resource other;");
}
assert_ne!(host, HOST_WIT);
let component = fixture(&host, PLUGIN_WIT, "https-tool-plugin");
assert!(
validate_component(&component, HTTPS, &https_imports())
.unwrap_err()
.contains("incorrect ABI"),
"mutation: {from} -> {to}"
);
}
}
#[test]
fn interface_declaration_order_is_not_part_of_the_abi() {
let (mut resolve, world) = contract("https-tool-plugin").unwrap();
for (_, interface) in resolve.interfaces.iter_mut() {
interface.types.reverse();
interface.functions.reverse();
}
let component = encode(&resolve, world);
assert!(validate_component(&component, HTTPS, &https_imports()).is_ok());
}
#[test]
fn docs_are_not_part_of_the_abi() {
let plugin = PLUGIN_WIT.replace(
"interface tool {",
"/// Different documentation.\ninterface tool {",
);
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
assert!(validate_component(&component, PURE, &[]).is_ok());
}
#[test]
fn core_module_wit_type_package_and_invalid_component_are_rejected() {
let (resolve, world) = contract("tool-plugin").unwrap();
let module = dummy_module(&resolve, world, ManglingAndAbi::Standard32);
assert!(
validate_component(&module, PURE, &[])
.unwrap_err()
.contains("core module")
);
let package = resolve.worlds[world].package.unwrap();
let types = wit_component::encode(&resolve, package).unwrap();
assert!(
validate_component(&types, PURE, &[])
.unwrap_err()
.contains("WIT type package")
);
let mut component = encode(&resolve, world);
component.pop();
assert!(validate_component(&component, PURE, &[]).is_err());
}
}
+510
View File
@@ -0,0 +1,510 @@
//! Offline schema admission shared by package consumers. No guest, DCDL evaluator,
//! import loader, or ambient JSON Schema retriever participates in validation.
use std::collections::{BTreeMap, BTreeSet};
use decodal::{Ast, Expr, ExprId, Literal, SyntaxTokenKind as Token};
use serde_json::Value;
const MAX_SCHEMA_BYTES: usize = 64 * 1024;
const MAX_DEPTH: usize = 32;
const MAX_NODES: usize = 4096;
const TOOL_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
/// Admit a bounded, offline JSON Schema 2020-12 document (the default dialect).
///
/// Unknown keywords/dialects, resource IDs, dynamic/anchor/external references,
/// dangling references and recursive reference graphs are unsupported and rejected.
/// Local JSON Pointer references must target schema positions, not annotation data.
/// This checks the schema, not a Tool payload; callers must still validate payloads.
pub fn validate_tool_schema(schema: &Value) -> Result<(), String> {
bound_json(schema)?;
let mut graph = BTreeMap::new();
inspect_tool_schema(schema, "", &mut graph)?;
let mut complete = BTreeMap::new();
for path in graph.keys() {
check_reference_graph(path, &graph, &mut BTreeSet::new(), &mut complete, 0)?;
}
// Use the fixed built-in meta-schema, never schema-directed dialect retrieval.
jsonschema::draft202012::meta::validate(schema)
.map_err(|error| format!("invalid Tool JSON Schema: {error}"))?;
// The compiler normally skips unreferenced $defs. Compile every admitted
// schema position through one in-memory resource, so invalid regexes/formats
// cannot hide in an unused definition. These URNs are generated, not input.
const RESOURCE: &str = "urn:yoi:plugin:tool-schema";
let resource = jsonschema::Resource::from_contents(schema.clone())
.map_err(|error| format!("invalid Tool JSON Schema resource: {error}"))?;
let all_schemas = serde_json::json!({
"allOf": graph.keys().map(|path| {
serde_json::json!({"$ref": format!("{RESOURCE}#{}", uri_fragment(path))})
}).collect::<Vec<_>>()
});
jsonschema::options()
.with_draft(jsonschema::Draft::Draft202012)
.with_resource(RESOURCE, resource)
.with_retriever(NoRetrieval)
.should_validate_formats(true)
.should_ignore_unknown_formats(false)
.build(&all_schemas)
.map_err(|error| format!("invalid Tool JSON Schema: {error}"))?;
Ok(())
}
struct NoRetrieval;
impl jsonschema::Retrieve for NoRetrieval {
fn retrieve(
&self,
_uri: &jsonschema::Uri<String>,
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
Err("external schema retrieval is forbidden".into())
}
}
fn bound_json(value: &Value) -> Result<(), String> {
let mut stack = vec![(value, 0)];
let mut count = 0;
while let Some((value, depth)) = stack.pop() {
count += 1;
if count > MAX_NODES || depth > MAX_DEPTH {
return Err("Tool JSON Schema exceeds static complexity limits".into());
}
match value {
Value::Array(values) => stack.extend(values.iter().map(|v| (v, depth + 1))),
Value::Object(values) => stack.extend(values.values().map(|v| (v, depth + 1))),
_ => {}
}
}
if serde_json::to_vec(value).map_err(|e| e.to_string())?.len() > MAX_SCHEMA_BYTES {
return Err("Tool JSON Schema exceeds static byte limit".into());
}
Ok(())
}
fn pointer_child(parent: &str, key: &str) -> String {
format!("{parent}/{}", key.replace('~', "~0").replace('/', "~1"))
}
fn uri_fragment(pointer: &str) -> String {
let mut encoded = String::new();
const HEX: &[u8; 16] = b"0123456789ABCDEF";
for byte in pointer.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 15)]));
}
}
encoded
}
fn inspect_tool_schema(
schema: &Value,
path: &str,
graph: &mut BTreeMap<String, Vec<String>>,
) -> Result<(), String> {
graph.insert(path.into(), Vec::new());
if schema.is_boolean() {
return Ok(());
}
let object = schema
.as_object()
.ok_or_else(|| format!("schema at #{path} must be an object or boolean"))?;
let mut children = Vec::new();
let mut references = Vec::new();
for (key, value) in object {
let location = pointer_child(path, key);
match key.as_str() {
"$schema" => {
if !path.is_empty() || value.as_str() != Some(TOOL_DIALECT) {
return Err(format!("unsupported Tool schema dialect at #{location}"));
}
}
"$ref" => {
let reference = value
.as_str()
.ok_or_else(|| format!("$ref at #{location} must be a string"))?;
let target = reference.strip_prefix('#').ok_or_else(|| {
format!("external Tool schema reference at #{location} is forbidden")
})?;
if (!target.is_empty() && !target.starts_with('/')) || target.contains('%') {
return Err(format!(
"only local JSON Pointer $ref is supported at #{location}"
));
}
references.push(target.to_owned());
}
"$defs" | "properties" | "patternProperties" | "dependentSchemas" => {
let entries = value
.as_object()
.ok_or_else(|| format!("schema map at #{location} must be an object"))?;
for (name, child) in entries {
children.push((pointer_child(&location, name), child));
}
}
"allOf" | "anyOf" | "oneOf" | "prefixItems" => {
let entries = value
.as_array()
.ok_or_else(|| format!("schema list at #{location} must be an array"))?;
for (index, child) in entries.iter().enumerate() {
children.push((pointer_child(&location, &index.to_string()), child));
}
}
"items"
| "contains"
| "additionalProperties"
| "unevaluatedProperties"
| "unevaluatedItems"
| "propertyNames"
| "not"
| "if"
| "then"
| "else" => {
children.push((location, value));
}
// These contain data, not subschemas. A property name or literal value
// resembling a keyword must not acquire schema semantics.
"type" | "enum" | "const" | "multipleOf" | "maximum" | "exclusiveMaximum"
| "minimum" | "exclusiveMinimum" | "maxLength" | "minLength" | "pattern" | "format"
| "maxItems" | "minItems" | "uniqueItems" | "maxContains" | "minContains"
| "maxProperties" | "minProperties" | "required" | "dependentRequired" | "title"
| "description" | "default" | "examples" | "deprecated" | "readOnly" | "writeOnly"
| "$comment" => {}
_ => return Err(format!("unsupported Tool schema keyword at #{location}")),
}
}
references.extend(children.iter().map(|(path, _)| path.clone()));
graph.insert(path.into(), references);
for (path, child) in children {
inspect_tool_schema(child, &path, graph)?;
}
Ok(())
}
fn check_reference_graph(
path: &str,
graph: &BTreeMap<String, Vec<String>>,
active: &mut BTreeSet<String>,
complete: &mut BTreeMap<String, usize>,
depth: usize,
) -> Result<usize, String> {
if active.contains(path) {
return Err(format!(
"recursive Tool schema reference at #{path} is unsupported"
));
}
if depth > MAX_DEPTH {
return Err("Tool schema reference graph exceeds static depth limit".into());
}
let children = graph
.get(path)
.ok_or_else(|| format!("Tool schema reference does not target a schema: #{path}"))?;
if let Some(height) = complete.get(path) {
if depth + height > MAX_DEPTH {
return Err("Tool schema reference graph exceeds static depth limit".into());
}
return Ok(*height);
}
active.insert(path.into());
let mut height = 0;
for child in children {
height = height.max(1 + check_reference_graph(child, graph, active, complete, depth + 1)?);
}
active.remove(path);
complete.insert(path.into(), height);
Ok(height)
}
/// Admit a closed static subset of actual DCDL syntax using decodal's parser/AST.
///
/// The root must be an object. Nested closed objects, homogeneous array constraints
/// (`[...String]`), primitive types (`String`, `Bool`, `Int`, `Float`) and scalar
/// singleton literals are supported. Object fields must have unique single-part
/// paths. Imports, open objects/maps, Unknown, defaults, references, expressions,
/// calls and user functions are rejected, not evaluated. This does not validate a
/// config instance or establish that its values contain no secrets.
pub fn validate_config_schema(source: &str) -> Result<(), String> {
if source.len() > MAX_SCHEMA_BYTES {
return Err("DCDL config schema exceeds static byte limit".into());
}
let tokens = decodal::tokenize_source(source)
.map_err(|error| format!("invalid DCDL config schema: {}", error.message))?;
if tokens.len() > MAX_NODES {
return Err("DCDL config schema exceeds static token limit".into());
}
// Guard parser recursion before parsing untrusted source. Forbid operators
// outside the subset here as well, so long right-associative expressions
// cannot consume the parser stack before the AST admission check.
let mut depth = 0usize;
for (index, token) in tokens.iter().enumerate() {
match &token.kind {
Token::LBrace | Token::LBracket | Token::LParen => {
depth += 1;
if depth > MAX_DEPTH {
return Err("DCDL config schema exceeds static depth limit".into());
}
}
Token::RBrace | Token::RBracket | Token::RParen => {
depth = depth.saturating_sub(1);
}
Token::Minus
if tokens
.get(index + 1)
.is_some_and(|next| matches!(next.kind, Token::Int(_) | Token::Float(_))) => {}
Token::Ident(_)
| Token::Int(_)
| Token::Float(_)
| Token::String(_)
| Token::True
| Token::False
| Token::Semicolon
| Token::Comma
| Token::Ellipsis
| Token::Colon
| Token::Equal
| Token::Comment
| Token::Eof => {}
_ => return Err("unsupported expression in static DCDL config schema".into()),
}
}
let parsed = decodal::parse_source(source)
.map_err(|error| format!("invalid DCDL config schema: {}", error.message))?;
let root = unparenthesized(&parsed.ast, parsed.root);
if !matches!(parsed.ast.get(root).expr, Expr::Object { .. }) {
return Err("DCDL config schema root must be a closed object".into());
}
inspect_config_schema(&parsed.ast, root)
}
fn unparenthesized(ast: &Ast, mut id: ExprId) -> ExprId {
while let Expr::Parenthesized { expr } = ast.get(id).expr {
id = expr;
}
id
}
fn inspect_config_schema(ast: &Ast, id: ExprId) -> Result<(), String> {
match &ast.get(unparenthesized(ast, id)).expr {
Expr::Object { fields, rest } => {
if rest.is_some() {
return Err(
"DCDL config schema objects must be closed (no rest constraint)".into(),
);
}
let mut names = BTreeSet::new();
for field in fields {
if field.path.len() != 1 || !names.insert(&field.path[0]) {
return Err("DCDL config schema requires unique single-part field paths".into());
}
inspect_config_schema(ast, field.value)?;
}
Ok(())
}
Expr::ArrayConstraint { item } => inspect_config_schema(ast, *item),
Expr::Ident(name) if matches!(name.as_str(), "String" | "Bool" | "Int" | "Float") => Ok(()),
Expr::Literal(Literal::Float(value)) if !value.is_finite() => {
Err("DCDL config schema requires finite scalar literals".into())
}
Expr::Literal(_) => Ok(()),
Expr::Unary {
op: decodal::ast::UnaryOp::Neg,
expr,
} if matches!(ast.get(*expr).expr, Expr::Literal(Literal::Int(_)))
|| matches!(ast.get(*expr).expr, Expr::Literal(Literal::Float(v)) if v.is_finite()) =>
{
Ok(())
}
_ => Err("unsupported node in closed static DCDL config schema".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tool_schema_accepts_typed_objects_and_local_definitions() {
let schema = json!({
"$schema": TOOL_DIALECT,
"$defs": {"entry/type": {"type": "string", "minLength": 1}},
"type": "object",
"properties": {
"label": {"$ref": "#/$defs/entry~1type"},
"values": {"type": "array", "items": {"type": "integer"}}
},
"required": ["label"],
"additionalProperties": false
});
validate_tool_schema(&schema).unwrap();
for schema in [json!({}), json!(true), json!(false)] {
validate_tool_schema(&schema).unwrap();
}
}
#[test]
fn tool_schema_rejects_invalid_keyword_shapes_and_types() {
for schema in [
json!(null),
json!([]),
json!({"type": "unknown"}),
json!({"required": "label"}),
json!({"properties": []}),
json!({"properties": {"nested": {"type": 3}}}),
json!({"allOf": []}),
json!({"items": []}),
json!({"minLength": -1}),
json!({"pattern": "["}),
json!({"required": ["x", "x"]}),
json!({"format": "unknown-format"}),
] {
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
}
}
#[test]
fn tool_schema_rejects_unknown_dialects_keywords_and_nested_extensions() {
for schema in [
json!({"$schema": "https://example.invalid/schema"}),
json!({"$schema": "http://json-schema.org/draft-07/schema#"}),
json!({"typo": true}),
json!({"$id": "https://example.invalid/base"}),
json!({"$dynamicRef": "#node"}),
json!({"$anchor": "node"}),
json!({"$defs": {"unused": {"typo": true}}}),
json!({"properties": {"x": {"$schema": TOOL_DIALECT}}}),
json!({"contentSchema": {"type": "string"}}),
] {
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
}
}
#[test]
fn tool_schema_rejects_external_dangling_and_recursive_references() {
for reference in [
"https://example.invalid/schema",
"file:///etc/passwd",
"other.json",
"//example.invalid/schema",
"urn:example:schema",
"#missing",
"#/$defs/missing",
"#",
"#/%24defs/x",
] {
assert!(
validate_tool_schema(&json!({"$ref": reference})).is_err(),
"{reference}"
);
}
for schema in [
json!({"$defs": {"unused": {"$ref": "https://example.invalid/schema"}}}),
json!({"default": {"type": "string"}, "$ref": "#/default"}),
json!({"$defs": {"a": {"$ref": "#/$defs/b"}, "b": {"$ref": "#/$defs/a"}}}),
] {
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
}
}
#[test]
fn tool_schema_rejects_invalid_unreferenced_definitions() {
for schema in [
json!({"$defs": {"unused": {"pattern": "["}}}),
json!({"$defs": {"unused": {"format": "unknown-format"}}}),
json!({"$defs": {"unused": {"properties": {"x": {"pattern": "["}}}}}),
] {
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
}
}
#[test]
fn tool_schema_treats_annotations_and_property_names_as_data() {
validate_tool_schema(&json!({
"properties": {
"$ref": {"type": "string"},
"unknown": {"const": {"typo": 1}},
"space # % ~ / 日本語": {"type": "string"}
},
"default": {"$ref": "https://example.invalid/data", "typo": 3},
"examples": [{"$schema": "not a schema"}]
}))
.unwrap();
}
#[test]
fn config_schema_accepts_closed_dcdl_types_and_singletons() {
for source in [
"{}",
"label = String; enabled = Bool; retries = Int; ratio = Float",
"{ nested = { label = String }; tags = [...String]; rows = [...{ id = Int }] }",
"{ mode = \"safe\"; enabled = true; count = 3; ratio = 0.5; offset = -2; delta = -1.5 }",
"# DCDL comment\n { label = (String) }",
] {
validate_config_schema(source).unwrap_or_else(|error| panic!("{source}: {error}"));
}
}
#[test]
fn config_schema_rejects_open_untyped_and_dynamic_dcdl() {
for source in [
"Unknown",
"String",
"[...String]",
"{ x = Unknown }",
"{ ...String }",
"{ x = String; ...Unknown }",
"{ nested = { ...Int } }",
"{ x = [...Unknown] }",
"import \"remote.dcdl\"",
"{ x = import \"/etc/passwd\" }",
"let T = String in { x = T }",
"{ x = MissingType }",
"{ x = env(\"TOKEN\") }",
"{ x = String default \"hello\" }",
"{ x = (v) => v }",
"{ x = 1 + 1 }",
"{ x = [] }",
"{ x = [String] }",
"{ x = _ }",
"{ x = String; x = Int }",
"{ x.y = String }",
"{ x = String",
"{\"type\": \"object\"}",
"{ x = ---1 }",
] {
assert!(validate_config_schema(source).is_err(), "accepted {source}");
}
}
#[test]
fn schemas_reject_oversize_and_deep_inputs_without_evaluation() {
assert!(validate_config_schema(&" ".repeat(MAX_SCHEMA_BYTES + 1)).is_err());
assert!(
validate_config_schema(&format!("{}String{}", "(".repeat(1000), ")".repeat(1000)))
.is_err()
);
assert!(
validate_tool_schema(&json!({"description": "x".repeat(MAX_SCHEMA_BYTES)})).is_err()
);
let mut nested = json!({});
for _ in 0..MAX_DEPTH + 1 {
nested = json!({"not": nested});
}
assert!(validate_tool_schema(&nested).is_err());
// Each suffix is visited and cached before the next, longer chain.
let mut definitions = serde_json::Map::new();
definitions.insert("n00".into(), json!({"type": "string"}));
for index in 1..MAX_DEPTH + 2 {
definitions.insert(
format!("n{index:02}"),
json!({
"$ref": format!("#/$defs/n{:02}", index - 1)
}),
);
}
let error = validate_tool_schema(&json!({"$defs": definitions})).unwrap_err();
assert!(error.contains("depth limit"), "{error}");
}
}
+3
View File
@@ -15,3 +15,6 @@ wit-bindgen = "0.51.0"
[dev-dependencies]
tempfile.workspace = true
toml.workspace = true
wasmparser = "0.244.0"
wit-component = "0.244.0"
wit-parser = "0.244.0"
+2 -6
View File
@@ -1,9 +1,5 @@
# plugin-pdk
Guest-side JSON/WIT helpers for offline Component Model packages. Without depending on host/runtime crates, it converts Tool and Service inputs/outputs and bounds Tool output size. Using the PDK or validating a package does not authorize installation or execution in a Worker.
Guest-side Rust helpers for the versioned Component Model Tool contract. [`ToolContext`] decodes only non-secret invocation data; [`ToolOutput`] rejects oversized data and separates structured output from presentation text. [`export_component_tool!`] adapts generated WIT bindings and preserves the result's error arm.
## Main entry points
- [`run_json_tool`] and [`export_component_tool!`] — typed Tool input/output boundary
- [`ToolOutput`] and [`ToolError`] — bounded output and errors
- [`ServiceOutput`] and [`export_plugin_instance!`] — instance output contracts
Host authorization, schema validation, effect receipts and execution belong to the Host, not this crate. The PDK does not import WASI, implement a Host, or provide Service/event/Workdir/WebSocket stubs. Old v1 packages require an explicit v2 rebuild rather than a compatibility adapter.
+184 -644
View File
@@ -2,146 +2,126 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
pub use wit_bindgen;
pub type Result<T> = std::result::Result<T, ToolError>;
/// Current Yoi Component Model Tool world targeted by this PDK.
pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
/// Repository WIT for the current Tool world, exposed for authoring tools and
/// tests. Runtime components should still generate bindings at compile time.
pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-tool-v1.wit");
/// Repository WIT for the grant-bound host APIs importable by Tool components.
pub const TOOL_WORLD: &str = "yoi:plugin/tool-plugin@2.0.0";
pub const HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0";
pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit");
pub const HOST_WIT: &str =
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit");
/// Serialized JSON limit defined by the offline Tool world's output contract.
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024;
/// Byte limit for summaries in the offline Tool world.
pub const MAX_SUMMARY_BYTES: usize = 1024;
/// Conservative content cap that leaves room for JSON framing and escaping.
pub const MAX_CONTENT_BYTES: usize = MAX_TOOL_OUTPUT_BYTES - MAX_SUMMARY_BYTES - 4096;
/// Maximum structured error message bytes retained by the PDK.
pub const MAX_TOOL_INPUT_BYTES: usize = 64 * 1024;
pub const MAX_CONFIG_BYTES: usize = 64 * 1024;
pub const MAX_ERROR_MESSAGE_BYTES: usize = 4096;
/// Per-call context passed to a typed JSON handler.
/// Non-secret call snapshot. Invocation identity tracks effects; it grants no authority.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolContext {
invocation_id: String,
tool_name: String,
config_json: String,
remaining_ms: u32,
}
impl ToolContext {
/// Create context for the manifest-declared Tool name selected by the host.
pub fn new(tool_name: impl Into<String>) -> Self {
pub fn new(
invocation_id: String,
tool_name: String,
config_json: String,
remaining_ms: u32,
) -> Self {
Self {
tool_name: bounded_text(tool_name.into(), MAX_SUMMARY_BYTES),
invocation_id,
tool_name,
config_json,
remaining_ms,
}
}
/// Manifest-declared Tool name supplied by the host runtime.
pub fn invocation_id(&self) -> &str {
&self.invocation_id
}
pub fn tool_name(&self) -> &str {
&self.tool_name
}
pub fn remaining_ms(&self) -> u32 {
self.remaining_ms
}
/// Decode the plan's non-secret config. Host validation remains authoritative.
pub fn config<T: DeserializeOwned>(&self) -> Result<T> {
parse_bounded_json(&self.config_json, MAX_CONFIG_BYTES)
}
}
/// Bounded Tool output formatted on the guest side.
///
/// Shorten text at character boundaries and recheck the serialized JSON limit, including escapes.
/// Typed JSON through [`Self::json`] errors on overflow rather than truncating and breaking
/// its structure. Providing this output format does not provide Plugin execution by Workers.
/// Output crosses WIT as a record, never as a success envelope hiding an error.
/// Oversized output is rejected, not silently truncated into invalid JSON.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ToolOutput {
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
structured_json: Option<String>,
}
impl ToolOutput {
/// Create an ordinary Tool output.
pub fn new(summary: impl Into<String>, content: Option<String>) -> Self {
let mut output = Self {
summary: normalize_summary(summary.into()),
content: content.map(|value| bounded_text(value, MAX_CONTENT_BYTES)),
};
output.bound_serialized_json();
output
pub fn new(summary: impl Into<String>, content: Option<String>) -> Result<Self> {
Self::checked(summary.into(), content, None)
}
/// Create a Tool output whose content is typed JSON.
pub fn json(
summary: impl Into<String>,
value: impl Serialize,
) -> std::result::Result<Self, ToolError> {
let content = serde_json::to_string(&value).map_err(ToolError::serialization)?;
pub fn summary(summary: impl Into<String>) -> Result<Self> {
Self::new(summary, None)
}
/// Structured JSON is separate from presentation text and needs a manifest output schema.
pub fn json(summary: impl Into<String>, value: impl Serialize) -> Result<Self> {
let json = serde_json::to_string(&value).map_err(ToolError::serialization)?;
Self::checked(summary.into(), None, Some(json))
}
fn checked(
summary: String,
content: Option<String>,
structured_json: Option<String>,
) -> Result<Self> {
let output = Self {
summary: normalize_summary(summary.into()),
content: Some(content),
summary,
content,
structured_json,
};
let serialized = serde_json::to_string(&output).map_err(ToolError::serialization)?;
if serialized.len() > MAX_TOOL_OUTPUT_BYTES {
return Err(ToolError::invalid_output(format!(
"serialized ToolOutput JSON exceeds {MAX_TOOL_OUTPUT_BYTES} bytes"
)));
let bytes = serde_json::to_vec(&output).map_err(ToolError::serialization)?;
if bytes.len() > MAX_TOOL_OUTPUT_BYTES {
return Err(ToolError::invalid_output(
"serialized output exceeds 64 KiB",
));
}
Ok(output)
}
/// Create a summary-only Tool output.
pub fn summary(summary: impl Into<String>) -> Self {
Self::new(summary, None)
}
/// Return the bounded summary.
pub fn summary_text(&self) -> &str {
&self.summary
}
/// Return optional detailed content.
pub fn content(&self) -> Option<&str> {
self.content.as_deref()
}
/// Serialize to the ToolOutput JSON string returned by the WIT `call` export.
pub fn to_json_string(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| {
r#"{"summary":"tool error: serialization","content":"{\"error\":{\"code\":\"serialization\",\"message\":\"failed to serialize ToolOutput\"}}"}"#.to_string()
})
pub fn structured_json(&self) -> Option<&str> {
self.structured_json.as_deref()
}
fn bound_serialized_json(&mut self) {
loop {
let Ok(serialized) = serde_json::to_string(self) else {
return;
};
if serialized.len() <= MAX_TOOL_OUTPUT_BYTES {
return;
}
let Some(content) = self.content.take() else {
return;
};
if content.is_empty() {
return;
}
let overflow = serialized.len() - MAX_TOOL_OUTPUT_BYTES;
let target = content.len().saturating_sub(overflow + "…".len());
self.content = Some(bounded_text(content, target));
}
#[doc(hidden)]
pub fn into_parts(self) -> (String, Option<String>, Option<String>) {
(self.summary, self.content, self.structured_json)
}
}
/// Stable, low-cardinality error codes for PDK-produced Tool errors.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
/// Guest business errors are not Host authorization decisions or effect receipts.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToolErrorCode {
InvalidInput,
InvalidOutput,
Serialization,
Denied,
UnsupportedTool,
Failed,
}
@@ -152,640 +132,200 @@ impl ToolErrorCode {
Self::InvalidInput => "invalid_input",
Self::InvalidOutput => "invalid_output",
Self::Serialization => "serialization",
Self::Denied => "denied",
Self::UnsupportedTool => "unsupported_tool",
Self::Failed => "failed",
}
}
}
/// Structured, bounded error that is rendered as ordinary ToolOutput JSON.
#[derive(Clone, Debug, PartialEq, Serialize, thiserror::Error)]
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("{code:?}: {message}")]
pub struct ToolError {
code: ToolErrorCode,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<Value>,
}
impl ToolError {
pub fn new(code: ToolErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: bounded_text(message.into(), MAX_ERROR_MESSAGE_BYTES),
details: None,
let mut message = message.into();
if message.len() > MAX_ERROR_MESSAGE_BYTES {
let mut end = MAX_ERROR_MESSAGE_BYTES;
while !message.is_char_boundary(end) {
end -= 1;
}
message.truncate(end);
}
Self { code, message }
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::InvalidInput, message)
}
pub fn invalid_output(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::InvalidOutput, message)
}
pub fn serialization(error: impl std::fmt::Display) -> Self {
Self::new(ToolErrorCode::Serialization, error.to_string())
/// Serialization diagnostics must not include the original payload.
pub fn serialization(_error: impl std::fmt::Display) -> Self {
Self::new(ToolErrorCode::Serialization, "output serialization failed")
}
pub fn denied(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::Denied, message)
pub fn unsupported_tool(_name: impl AsRef<str>) -> Self {
Self::new(ToolErrorCode::UnsupportedTool, "unsupported Tool name")
}
pub fn unsupported_tool(tool_name: impl Into<String>) -> Self {
Self::new(
ToolErrorCode::UnsupportedTool,
format!("unsupported tool `{}`", tool_name.into()),
)
}
pub fn failed(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::Failed, message)
}
pub fn with_details(mut self, details: impl Serialize) -> Self {
self.details = serde_json::to_value(details).ok();
self
}
pub fn code(&self) -> ToolErrorCode {
self.code
}
pub fn code_str(&self) -> &'static str {
self.code.as_str()
}
pub fn message(&self) -> &str {
&self.message
}
}
/// Render this error as ordinary ToolOutput JSON content.
pub fn into_tool_output(self) -> ToolOutput {
#[derive(Serialize)]
struct ErrorBody<'a> {
error: ErrorPayload<'a>,
}
#[derive(Serialize)]
struct ErrorPayload<'a> {
code: &'static str,
message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<&'a Value>,
}
let summary = format!("tool error: {}", self.code.as_str());
let payload = ErrorBody {
error: ErrorPayload {
code: self.code.as_str(),
message: &self.message,
details: self.details.as_ref(),
},
};
let mut content = serde_json::to_string(&payload).unwrap_or_else(|_| {
format!(
r#"{{"error":{{"code":"{}","message":"failed to serialize error details"}}}}"#,
ToolErrorCode::Serialization.as_str()
)
});
if content.len() > MAX_CONTENT_BYTES {
let payload = ErrorBody {
error: ErrorPayload {
code: self.code.as_str(),
message: &self.message,
details: None,
},
};
content = serde_json::to_string(&payload).unwrap_or_else(|_| {
r#"{"error":{"code":"serialization","message":"failed to serialize error"}}"#
.to_string()
});
}
ToolOutput::new(summary, Some(content))
fn parse_bounded_json<T: DeserializeOwned>(json: &str, limit: usize) -> Result<T> {
if json.len() > limit {
return Err(ToolError::invalid_input("JSON exceeds 64 KiB"));
}
serde_json::from_str(json)
.map_err(|_| ToolError::invalid_input("JSON does not match the expected schema"))
}
/// Parse the WIT `input-json` string into a typed input value.
pub fn parse_json_input<T>(input_json: &str) -> std::result::Result<T, ToolError>
where
T: DeserializeOwned,
{
serde_json::from_str(input_json).map_err(|error| {
ToolError::invalid_input(format!(
"tool input is not valid JSON for this schema: {error}"
))
})
pub fn parse_json_input<T: DeserializeOwned>(json: &str) -> Result<T> {
parse_bounded_json(json, MAX_TOOL_INPUT_BYTES)
}
/// Guest-side boundary that runs a typed JSON handler and converts the result to ToolOutput JSON.
///
/// Convert parse, handler, and serialization failures to the normal output form. These errors
/// are not host authorization decisions, and catching handler panics is not guaranteed.
/// The PDK does not depend on host/runtime crates or grant permissions through input conversion.
pub fn run_json_tool<I, F>(tool_name: &str, input_json: &str, handler: F) -> String
where
I: DeserializeOwned,
F: FnOnce(ToolContext, I) -> std::result::Result<ToolOutput, ToolError>,
{
let result = parse_json_input::<I>(input_json).and_then(|input| {
let context = ToolContext::new(tool_name);
handler(context, input)
});
match result {
Ok(output) => output.to_json_string(),
Err(error) => error.into_tool_output().to_json_string(),
}
/// Host performs schema and admission checks; this boundary only decodes guest data.
/// Failures remain the WIT result's error arm. No retry or success stub is introduced.
pub fn run_json_tool<I: DeserializeOwned>(
context: ToolContext,
input_json: &str,
handler: impl FnOnce(ToolContext, I) -> Result<ToolOutput>,
) -> Result<ToolOutput> {
let _: serde_json::Value = context.config()?;
let input = parse_json_input(input_json)?;
handler(context, input)
}
/// Connect a typed JSON handler to a WIT `Guest` implementation and component export.
///
/// Use guest-generated bindings and centralize handler conversion in [`run_json_tool`].
/// This macro grants no host API permissions and is not a mechanism for registering or
/// executing dynamic Plugins in the current Worker.
/// Adapt bindings generated from any supported v2 Tool world. The generated glue
/// owns the canonical ABI; callers never implement pointer/length exports.
#[macro_export]
macro_rules! export_component_tool {
($adapter:ident, $handler:path) => {
struct $adapter;
impl Guest for $adapter {
fn call(
tool_name: ::std::string::String,
input_json: ::std::string::String,
) -> ::std::string::String {
$crate::run_json_tool(&tool_name, &input_json, $handler)
impl exports::yoi::plugin::tool::Guest for $adapter {
fn invoke(
input: exports::yoi::plugin::tool::Invocation,
) -> ::std::result::Result<
exports::yoi::plugin::tool::Output,
exports::yoi::plugin::tool::ToolError,
> {
let context = $crate::ToolContext::new(
input.invocation_id,
input.tool_name,
input.config_json,
input.remaining_ms,
);
$crate::run_json_tool(context, &input.input_json, $handler)
.map(|output| {
let (summary, content, structured_json) = output.into_parts();
exports::yoi::plugin::tool::Output {
summary,
content,
structured_json,
}
})
.map_err(|error| exports::yoi::plugin::tool::ToolError {
code: error.code_str().to_owned(),
message: error.message().to_owned(),
})
}
}
export!($adapter);
};
}
fn normalize_summary(summary: String) -> String {
let summary = bounded_text(summary, MAX_SUMMARY_BYTES);
if summary.trim().is_empty() {
"tool completed".to_string()
} else {
summary
}
}
fn bounded_text(mut value: String, max_bytes: usize) -> String {
if max_bytes == 0 {
return String::new();
}
value = value
.chars()
.map(|ch| {
if ch.is_control() && ch != '\n' && ch != '\t' {
' '
} else {
ch
}
})
.collect();
if value.len() <= max_bytes {
return value;
}
let suffix = "…";
let budget = max_bytes.saturating_sub(suffix.len());
let mut cut = budget.min(value.len());
while cut > 0 && !value.is_char_boundary(cut) {
cut -= 1;
}
value.truncate(cut);
value.push_str(suffix);
value
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Deserialize)]
struct EchoInput {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {
text: String,
}
#[derive(Debug, Serialize)]
struct EchoOutput<'a> {
tool: &'a str,
text: String,
fn context(config: &str) -> ToolContext {
ToolContext::new("call-1".into(), "echo".into(), config.into(), 500)
}
#[test]
fn run_json_tool_parses_input_and_serializes_output() {
let output = run_json_tool(
"example_echo",
r#"{"text":"hello"}"#,
|ctx, input: EchoInput| {
ToolOutput::json(
format!("{} ok", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
)
},
);
let value: Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["summary"], "example_echo ok");
let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap();
assert_eq!(content, json!({"tool":"example_echo","text":"hello"}));
assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES);
}
#[test]
fn run_json_tool_renders_error_output() {
let output =
run_json_tool::<EchoInput, _>("example_echo", r#"{"text":1}"#, |_ctx, _input| {
Ok(ToolOutput::summary("unreachable"))
});
let value: Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["summary"], "tool error: invalid_input");
let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap();
assert_eq!(content["error"]["code"], "invalid_input");
assert!(content["error"]["message"].as_str().unwrap().len() <= MAX_ERROR_MESSAGE_BYTES);
assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES);
}
#[test]
fn json_output_rejects_oversized_serialized_tool_output() {
let too_large = vec!["quoted \" text"; MAX_TOOL_OUTPUT_BYTES / 4];
let error = ToolOutput::json("too large", too_large).unwrap_err();
assert_eq!(error.code(), ToolErrorCode::InvalidOutput);
}
#[test]
fn tool_error_bounds_message_and_control_characters() {
let message = format!("bad\u{0007}{}", "x".repeat(MAX_ERROR_MESSAGE_BYTES * 2));
let error = ToolError::failed(message);
assert_eq!(error.code_str(), "failed");
assert!(!error.message().contains('\u{0007}'));
assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "…".len());
}
#[test]
fn service_output_helper_builds_runtime_command_envelope() {
let event = PluginIngressEvent {
kind: "websocket_text".to_string(),
source: "websocket:wss://example.test/socket".to_string(),
ingress_name: "example_ws".to_string(),
payload: json!({"text":"ping"}),
created_at: "2026-06-25T00:00:00Z".to_string(),
attempt: 1,
correlation_id: "event-1".to_string(),
};
assert_eq!(event.websocket_text(), Some("ping"));
let output =
ServiceOutput::websocket_send(&event, "reply-1", "wss://example.test/socket", "pong")
.unwrap();
let value = serde_json::to_value(output).unwrap();
assert_eq!(value["accepted"], true);
assert_eq!(value["output_commands"][0]["source_event_id"], "event-1");
assert_eq!(value["output_commands"][0]["command_id"], "reply-1");
assert_eq!(value["output_commands"][0]["kind"], "websocket_send");
assert_eq!(value["output_commands"][0]["payload"]["text"], "pong");
fn typed_handler_keeps_structured_output_separate_from_text() {
let output = run_json_tool(context("{}"), r#"{"text":"hello"}"#, |ctx, input: Input| {
assert_eq!(ctx.invocation_id(), "call-1");
assert_eq!(ctx.remaining_ms(), 500);
ToolOutput::json(
"echoed",
json!({"text": input.text, "tool": ctx.tool_name()}),
)
})
.unwrap();
assert_eq!(output.content(), None);
assert_eq!(
value["output_commands"][0]["requested_at"],
"2026-06-25T00:00:00Z"
serde_json::from_str::<serde_json::Value>(output.structured_json().unwrap()).unwrap(),
json!({"text":"hello", "tool":"echo"})
);
}
#[test]
fn wit_constants_match_current_world() {
assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0"));
assert!(TOOL_WIT.contains("world tool"));
assert!(TOOL_WIT.contains("export call"));
assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0");
assert!(HOST_WIT.contains("interface request"));
assert!(HOST_WIT.contains("interface websocket"));
assert!(HOST_WIT.contains("interface fs"));
assert!(HOST_WIT.contains("%list: func"));
assert!(INSTANCE_WIT.contains("world instance"));
assert!(INSTANCE_WIT.contains("export handle-ingress"));
assert!(INSTANCE_WIT.contains("websocket_send"));
}
}
/// Versioned Component Model instance world handled by the host-managed
/// PluginInstanceRegistry.
pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
/// Repository WIT for the current instance world.
pub const INSTANCE_WIT: &str =
include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit");
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PluginIngressEvent {
pub kind: String,
pub source: String,
#[serde(default)]
pub ingress_name: String,
#[serde(default)]
pub payload: Value,
#[serde(default)]
pub created_at: String,
#[serde(default = "default_attempt")]
pub attempt: u32,
#[serde(default)]
pub correlation_id: String,
}
impl PluginIngressEvent {
/// Return the text payload carried by a host-owned WebSocket ingress event.
pub fn websocket_text(&self) -> Option<&str> {
self.payload.get("text").and_then(Value::as_str)
}
/// Build a `websocket_send` output command that replies through the
/// host-owned Service WebSocket driver. The host still validates the target
/// URL and matching grants before sending.
pub fn websocket_send(
&self,
command_id: impl Into<String>,
url: impl Into<String>,
text: impl Into<String>,
) -> Result<ServiceOutputCommand> {
ServiceOutputCommand::new(
self,
command_id,
ServiceOutputCommandKind::WebSocketSend,
serde_json::json!({ "url": url.into(), "text": text.into() }),
)
}
}
fn default_attempt() -> u32 {
1
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct PluginStatus {
pub state: String,
#[serde(default)]
pub data: Value,
}
impl PluginStatus {
pub fn ready(data: Value) -> Self {
Self {
state: "ready".to_string(),
data,
fn invalid_input_or_config_never_runs_handler_or_returns_success() {
for (config, input) in [
("{}", r#"{"text":1}"#),
("broken", r#"{"text":"ok"}"#),
("{}", r#"{"text":"ok","extra":true}"#),
] {
let error = run_json_tool::<Input>(context(config), input, |_, _| {
panic!("handler must not run")
})
.unwrap_err();
assert_eq!(error.code(), ToolErrorCode::InvalidInput);
}
}
pub fn stopped() -> Self {
Self {
state: "stopped".to_string(),
data: Value::Null,
#[test]
fn payload_limits_reject_overflow_including_json_escaping() {
for text in [
"x".repeat(MAX_TOOL_OUTPUT_BYTES),
"\"".repeat(MAX_TOOL_OUTPUT_BYTES / 2),
"語".repeat(MAX_TOOL_OUTPUT_BYTES / 3),
] {
assert_eq!(
ToolOutput::json("large", &text).unwrap_err().code(),
ToolErrorCode::InvalidOutput
);
}
assert!(
parse_json_input::<serde_json::Value>(&format!(
"\"{}\"",
"x".repeat(MAX_TOOL_INPUT_BYTES)
))
.is_err()
);
let large = format!("\"{}\"", "x".repeat(MAX_CONFIG_BYTES));
assert!(context(&large).config::<serde_json::Value>().is_err());
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceOutputCommandKind {
DiagnosticStatusUpdate,
HostRequestDispatch,
#[serde(rename = "websocket_send")]
WebSocketSend,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ServiceOutputCommand {
pub correlation_id: String,
pub source_event_id: String,
pub command_id: String,
pub kind: ServiceOutputCommandKind,
pub payload: Value,
pub requested_at: String,
}
impl ServiceOutputCommand {
pub fn new(
event: &PluginIngressEvent,
command_id: impl Into<String>,
kind: ServiceOutputCommandKind,
payload: impl Serialize,
) -> Result<Self> {
let command_id = sanitize_command_id(command_id.into());
if command_id.is_empty() {
return Err(ToolError::invalid_output(
"service output command_id must not be empty",
));
}
let source_event_id = event.correlation_id.clone();
if source_event_id.is_empty() {
return Err(ToolError::invalid_output(
"service output command requires ingress event correlation_id",
));
}
let requested_at = if event.created_at.is_empty() {
"1970-01-01T00:00:00Z".to_string()
} else {
event.created_at.clone()
};
Ok(Self {
correlation_id: sanitize_command_id(format!("{source_event_id}:{command_id}")),
source_event_id,
command_id,
kind,
payload: serde_json::to_value(payload).map_err(ToolError::serialization)?,
requested_at,
#[test]
fn business_failure_is_preserved_and_message_is_utf8_bounded() {
let error = run_json_tool(context("{}"), "null", |_, _: ()| {
Err(ToolError::failed("語".repeat(MAX_ERROR_MESSAGE_BYTES)))
})
.unwrap_err();
assert_eq!(error.code_str(), "failed");
assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES);
assert!(error.message().ends_with('語'));
}
}
/// Instance processing results and output commands requested from the host.
///
/// The guest does not send anything here; it returns commands as data tied to the original
/// ingress event. Command creation/serialization and host authorization/execution are separate
/// boundaries. This type provides neither network permissions nor the current Worker execution mechanism.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ServiceOutput {
#[serde(default)]
pub accepted: bool,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub data: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output_commands: Vec<ServiceOutputCommand>,
}
impl ServiceOutput {
pub fn accepted(data: impl Serialize) -> Result<Self> {
Ok(Self {
accepted: true,
data: serde_json::to_value(data).map_err(ToolError::serialization)?,
output_commands: Vec::new(),
})
}
pub fn empty() -> Self {
Self {
accepted: true,
data: Value::Null,
output_commands: Vec::new(),
}
}
pub fn with_command(mut self, command: ServiceOutputCommand) -> Self {
self.output_commands.push(command);
self
}
pub fn websocket_send(
event: &PluginIngressEvent,
command_id: impl Into<String>,
url: impl Into<String>,
text: impl Into<String>,
) -> Result<Self> {
Ok(Self::empty().with_command(event.websocket_send(command_id, url, text)?))
}
}
fn sanitize_command_id(value: String) -> String {
bounded_text(
value
.chars()
.map(|ch| if ch.is_control() { '-' } else { ch })
.collect(),
128,
)
}
/// Rust-facing instance Plugin contract. Hosts call `start` once, then route
/// Tool/Ingress surfaces through the same mutable instance.
pub trait Plugin: Sized + 'static {
fn start(config: Value) -> Result<Self>;
fn handle_tool(&mut self, name: &str, input: Value) -> Result<ToolOutput>;
fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result<ServiceOutput>;
fn status(&self) -> Result<PluginStatus> {
Ok(PluginStatus::ready(Value::Null))
}
fn stop(&mut self) -> Result<PluginStatus> {
Ok(PluginStatus::stopped())
}
}
#[doc(hidden)]
pub fn plugin_instance_error(message: impl Into<String>) -> String {
serde_json::json!({ "error": { "message": message.into() } }).to_string()
}
#[doc(hidden)]
pub fn plugin_instance_status(status: &PluginStatus) -> String {
serde_json::to_string(status).unwrap_or_else(|error| plugin_instance_error(error.to_string()))
}
/// Implement the generated Component Model `Guest` trait for an instance Plugin
/// and export it with the `wit-bindgen` generated `export!` macro.
///
/// The caller must invoke `wit_bindgen::generate!` for the `instance` world
/// first, with `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`. That defines
/// the `Guest` trait and `export!` macro in the current module.
#[macro_export]
macro_rules! export_plugin_instance {
($adapter:ident, $plugin:ty) => {
struct $adapter;
thread_local! {
static YOI_PLUGIN_INSTANCE: ::std::cell::RefCell<::std::option::Option<$plugin>> = const { ::std::cell::RefCell::new(None) };
}
impl Guest for $adapter {
fn start(config_json: ::std::string::String) -> ::std::string::String {
let config = serde_json::from_str(&config_json).unwrap_or(serde_json::Value::Null);
match <$plugin as $crate::Plugin>::start(config) {
Ok(plugin) => {
YOI_PLUGIN_INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin));
$crate::plugin_instance_status(&$crate::PluginStatus::ready(serde_json::Value::Null))
}
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
}
fn handle_tool(
name: ::std::string::String,
input_json: ::std::string::String,
) -> ::std::string::String {
let input = serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null);
YOI_PLUGIN_INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.handle_tool(&name, input) {
Ok(output) => output.to_json_string(),
Err(error) => error.into_tool_output().to_json_string(),
}
})
}
fn handle_ingress(
name: ::std::string::String,
event_json: ::std::string::String,
) -> ::std::string::String {
let event = match serde_json::from_str::<$crate::PluginIngressEvent>(&event_json) {
Ok(event) => event,
Err(error) => return $crate::plugin_instance_error(error.to_string()),
};
YOI_PLUGIN_INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.handle_ingress(&name, event) {
Ok(output) => serde_json::to_string(&output)
.unwrap_or_else(|error| $crate::plugin_instance_error(error.to_string())),
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
fn status() -> ::std::string::String {
YOI_PLUGIN_INSTANCE.with(|slot| {
let slot = slot.borrow();
let Some(plugin) = slot.as_ref() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.status() {
Ok(status) => $crate::plugin_instance_status(&status),
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
fn stop() -> ::std::string::String {
YOI_PLUGIN_INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.stop() {
Ok(status) => {
let output = $crate::plugin_instance_status(&status);
*slot = None;
output
}
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
}
export!($adapter);
};
}
@@ -1,38 +0,0 @@
//! Minimal Component Model Tool plugin authoring sketch using `yoi-plugin-pdk`.
//!
//! Build this as a `wasm32-unknown-unknown` cdylib with Component Model tooling
//! and package the adapted component as `plugin.component.wasm`.
use serde::{Deserialize, Serialize};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
wit_bindgen::generate!({
world: "tool",
path: "../../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
#[derive(Debug, Deserialize)]
struct EchoInput {
text: String,
}
#[derive(Debug, Serialize)]
struct EchoOutput<'a> {
tool: &'a str,
text: String,
}
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
ToolOutput::json(
format!("{} ok", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
)
}
yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);
Binary file not shown.
Binary file not shown.
+157 -198
View File
@@ -1,247 +1,206 @@
//! Check the shipped templates against the real PDK and WIT for the guest target.
//! Each check owns its source and lockfile; Cargo alone shares and validates artifacts.
//! Real Rust -> core Wasm -> Component coverage for the shipped starters.
//! Only filesystem paths are relocated; WIT and guest source are unchanged.
//! Requires the wasm32-unknown-unknown Rust std target and cached Cargo dependencies.
//! No guest is instantiated and no HTTPS Host is faked by this suite.
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
use toml::Value;
use wit_parser::WorldItem;
const TEMPLATE_CARGO: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/Cargo.toml");
const TEMPLATE_LIB: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/src/lib.rs");
const TEMPLATE_PLUGIN: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml");
const SERVICE_TEMPLATE_CARGO: &str =
include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml");
const SERVICE_TEMPLATE_LIB: &str =
include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs");
const SERVICE_TEMPLATE_PLUGIN: &str =
include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml");
// Preserve the removed document's source as a byte-identical fixture to keep the test input unchanged.
const SAMPLE_LIB: &str = include_str!("fixtures/component-tool-source.txt");
const PDK_CARGO: &str = include_str!("../Cargo.toml");
const TEMPLATES: [&str; 2] = ["rust-component-tool", "rust-component-https"];
#[test]
fn rust_component_tool_template_has_expected_files() {
let cargo: Value = toml::from_str(TEMPLATE_CARGO).expect("template Cargo.toml parses");
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
assert!(cargo["workspace"].is_table());
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
assert_eq!(
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
Some("../../../../crates/plugin-pdk")
);
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses");
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
assert_eq!(
plugin["runtime"]["world"].as_str(),
Some("yoi:plugin/tool@1.0.0")
);
assert!(plugin["tools"].as_array().expect("tools array").len() == 1);
assert!(TEMPLATE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
assert!(TEMPLATE_LIB.contains("wit_bindgen::generate!"));
assert!(TEMPLATE_LIB.contains("generate_all"));
assert!(TEMPLATE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
assert!(TEMPLATE_LIB.contains("ToolOutput::json"));
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap()
}
#[test]
fn rust_component_service_template_has_event_output_pattern() {
let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses");
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
assert_eq!(
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
Some("../../../../crates/plugin-pdk")
);
assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
let plugin: Value =
toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses");
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
assert_eq!(
plugin["runtime"]["world"].as_str(),
Some("yoi:plugin/instance@1.0.0")
);
assert!(
plugin["permissions"]
.as_array()
.expect("permissions array")
.iter()
.any(|permission| permission["kind"].as_str() == Some("host_api")
&& permission["api"].as_str() == Some("websocket"))
);
assert_eq!(
plugin["services"].as_array().expect("services array").len(),
1
);
let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0];
assert!(
ingress["event_kinds"]
.as_array()
.expect("event kinds")
.iter()
.any(|kind| kind.as_str() == Some("websocket_text"))
);
assert!(
ingress["sources"]
.as_array()
.expect("sources")
.iter()
.any(|source| source.as_str() == Some("websocket:wss://example.com/socket"))
);
let websocket = &plugin["websocket"].as_array().expect("websocket targets")[0];
assert_eq!(websocket["scheme"].as_str(), Some("wss"));
assert_eq!(websocket["host"].as_str(), Some("example.com"));
assert!(
websocket["path_prefixes"]
.as_array()
.expect("websocket path prefixes")
.iter()
.any(|prefix| prefix.as_str() == Some("/socket"))
);
assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\""));
assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent"));
assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send"));
assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout"));
assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\""));
fn template_wit_copies_match_the_versioned_contract() {
let root = root();
for template in TEMPLATES {
for file in ["yoi-plugin-v2.wit", "deps/yoi-host/yoi-host-v2.wit"] {
assert_eq!(
fs::read(root.join("resources/plugin/wit").join(file)).unwrap(),
fs::read(
root.join("resources/plugin/templates")
.join(template)
.join("wit")
.join(file)
)
.unwrap(),
"{template}/{file} must be regenerated from the canonical WIT"
);
}
}
}
#[test]
fn component_tool_fixture_uses_pdk_bindings() {
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
assert!(SAMPLE_LIB.contains("wit_bindgen::generate!"));
assert!(SAMPLE_LIB.contains("generate_all"));
assert!(SAMPLE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
assert!(!SAMPLE_LIB.contains("export_name"));
fn template_manifest_worlds_use_exact_versions_and_schemas() {
for template in TEMPLATES {
let path = root().join("resources/plugin/templates").join(template);
let cargo: Value =
toml::from_str(&fs::read_to_string(path.join("Cargo.toml")).unwrap()).unwrap();
assert!(cargo["workspace"].is_table());
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
let manifest: Value =
toml::from_str(&fs::read_to_string(path.join("plugin.toml")).unwrap()).unwrap();
assert_eq!(manifest["schema_version"].as_integer(), Some(2));
assert_eq!(manifest["runtime"]["kind"].as_str(), Some("wasm-component"));
assert!(
manifest["runtime"]["world"]
.as_str()
.unwrap()
.ends_with("@2.0.0")
);
for tool in manifest["tools"].as_array().unwrap() {
for schema in ["input_schema", "output_schema"] {
assert_eq!(tool[schema]["type"].as_str(), Some("object"));
assert_eq!(tool[schema]["additionalProperties"].as_bool(), Some(false));
}
}
}
}
#[test]
fn embedded_template_cargo_checks_for_wasm_target() {
cargo_check_template("rust-component-tool");
fn pure_starter_builds_real_component_without_imports() {
build_component(
"rust-component-tool",
"yoi_rust_component_tool_template",
&[],
);
}
#[test]
fn embedded_service_template_cargo_checks_for_wasm_target() {
cargo_check_template("rust-component-instance");
fn https_starter_builds_real_component_with_only_explicit_host_imports() {
build_component(
"rust-component-https",
"yoi_rust_component_https_template",
&[
"yoi:host/connections@2.0.0",
"yoi:host/errors@2.0.0",
"yoi:host/http@2.0.0",
],
);
}
fn cargo_check_template(template: &str) {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let templates_dir = crate_dir.join("../../resources/plugin/templates");
let template_dir = templates_dir.join(template);
// A sibling fixture preserves the shipped relative PDK and WIT paths verbatim.
// Never create or remove a lockfile in the shipped template: another invocation
// may be checking it at the same time, or a developer may own that lockfile.
fn build_component(template: &str, library: &str, expected_imports: &[&str]) {
let root = root();
let sdk = root.join("target/sdk");
fs::create_dir_all(&sdk).unwrap();
let source = root.join("resources/plugin/templates").join(template);
let fixture = tempfile::Builder::new()
.prefix(".cargo-check-")
.tempdir_in(&templates_dir)
.expect("temporary template fixture");
copy_template_tree(&template_dir, fixture.path(), true);
// Both templates share the PDK dependencies. A separate persistent target
// reuses those artifacts without sharing source or the outer Cargo build lock.
// Cargo fingerprints and its target lock handle changes and concurrent checks.
let executable = std::env::current_exe().expect("current test executable");
let target_dir = executable
.parent()
.and_then(|deps| deps.parent())
.expect("test executable has a profile directory")
.join("compile-tests/plugin-pdk-templates");
.prefix("build-")
.tempdir_in(&sdk)
.unwrap();
copy_tree(&source, fixture.path());
let cargo_path = fixture.path().join("Cargo.toml");
let cargo = fs::read_to_string(&cargo_path).unwrap().replace(
"../../../../crates/plugin-pdk",
root.join("crates/plugin-pdk").to_str().unwrap(),
);
fs::write(&cargo_path, cargo).unwrap();
let target = sdk.join("guest");
let output = Command::new(env!("CARGO"))
.arg("check")
.args([
"build",
"--release",
"--offline",
"--target",
"wasm32-unknown-unknown",
"--target-dir",
])
.arg(&target)
.arg("--manifest-path")
.arg(fixture.path().join("Cargo.toml"))
.arg("--target")
.arg("wasm32-unknown-unknown")
.arg("--offline")
.arg("--target-dir")
.arg(target_dir)
.arg(&cargo_path)
.current_dir(fixture.path())
.env("CARGO_TERM_COLOR", "never")
.output()
.expect("spawn cargo check for embedded template");
.expect("run guest Cargo build");
let logs = sdk.join("logs");
fs::create_dir_all(&logs).unwrap();
fs::write(logs.join(format!("{template}.stdout.log")), &output.stdout).unwrap();
fs::write(logs.join(format!("{template}.stderr.log")), &output.stderr).unwrap();
assert!(
output.status.success(),
"template {template} cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
output.status,
"{template}: cargo build failed\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let core =
fs::read(target.join(format!("wasm32-unknown-unknown/release/{library}.wasm"))).unwrap();
let component = wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap();
wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all())
.validate_all(&component)
.unwrap();
let wit_parser::decoding::DecodedWasm::Component(resolve, world) =
wit_component::decode(&component).unwrap()
else {
panic!("expected Component, not WIT package")
};
let world = &resolve.worlds[world];
let mut imports: Vec<_> = world
.imports
.keys()
.map(|key| resolve.name_world_key(key))
.collect();
imports.sort();
assert_eq!(imports, expected_imports);
let exports: Vec<_> = world
.exports
.keys()
.map(|key| resolve.name_world_key(key))
.collect();
assert_eq!(exports, ["yoi:plugin/tool@2.0.0"]);
let WorldItem::Interface { id, .. } = world.exports.values().next().unwrap() else {
panic!("Tool interface export required")
};
assert_eq!(
resolve.interfaces[*id].functions.keys().collect::<Vec<_>>(),
[&"invoke".to_string()]
);
// Retain actual package inputs for the shared validator/pack owner, not a mock .wasm.
let package = sdk.join("fixtures").join(template);
fs::create_dir_all(&package).unwrap();
fs::copy(source.join("plugin.toml"), package.join("plugin.toml")).unwrap();
fs::write(package.join("plugin.component.wasm"), component).unwrap();
}
fn copy_template_tree(source: &Path, destination: &Path, is_root: bool) {
let metadata = fs::symlink_metadata(source).expect("read template directory metadata");
assert!(
metadata.is_dir() && !metadata.file_type().is_symlink(),
"template input must be a real directory: {}",
source.display()
);
for entry in fs::read_dir(source).expect("read template directory") {
let entry = entry.expect("read template entry");
let path = entry.path();
let kind = entry.file_type().expect("read template entry type");
assert!(
!kind.is_symlink() && (kind.is_dir() || kind.is_file()),
"unsupported template input (including symlinks): {}",
path.display()
);
fn copy_tree(source: &Path, destination: &Path) {
fs::create_dir_all(destination).unwrap();
for entry in fs::read_dir(source).unwrap() {
let entry = entry.unwrap();
let kind = entry.file_type().unwrap();
assert!(!kind.is_symlink(), "template may not contain symlinks");
let name = entry.file_name();
// Preserve all compiler inputs, including build scripts and nested assets.
// Only omit the root build output and lockfile: each isolated consumer must
// resolve its own lockfile, as the original unlocked template check did.
if is_root
&& ((kind.is_dir() && name == "target") || (kind.is_file() && name == "Cargo.lock"))
{
if name == "target" || name == "Cargo.lock" {
continue;
}
let copied = destination.join(name);
if kind.is_dir() {
fs::create_dir(&copied).expect("create template fixture directory");
copy_template_tree(&path, &copied, false);
copy_tree(&entry.path(), &copied);
} else {
fs::copy(&path, &copied).expect("copy unmodified template input");
fs::copy(entry.path(), copied).unwrap();
}
}
}
#[test]
fn pdk_runtime_dependencies_are_guest_side_only() {
let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses");
let dependencies = cargo["dependencies"]
.as_table()
.expect("dependencies table");
let forbidden = [
"worker",
"yoi-worker",
"agen",
"tui",
"yoi-tui",
"client",
"yoi-client",
"manifest",
"yoi-manifest",
"ticket",
"yoi-ticket",
];
for name in forbidden {
let cargo: Value = toml::from_str(include_str!("../Cargo.toml")).unwrap();
for dependency in cargo["dependencies"].as_table().unwrap().keys() {
assert!(
!dependencies.contains_key(name),
"PDK must not depend on host/runtime crate `{name}`"
["serde", "serde_json", "thiserror", "wit-bindgen"].contains(&dependency.as_str()),
"unexpected Host dependency {dependency}"
);
}
}
+33 -11
View File
@@ -1,25 +1,47 @@
//! Compile-pass coverage for generated v2 bindings and the public adapter macro.
use yoi_plugin_pdk::wit_bindgen;
wit_bindgen::generate!({
world: "tool",
world: "tool-plugin",
path: "../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
struct Probe;
fn handle(
ctx: yoi_plugin_pdk::ToolContext,
input: serde_json::Value,
) -> yoi_plugin_pdk::Result<yoi_plugin_pdk::ToolOutput> {
if ctx.tool_name() != "probe" {
return Err(yoi_plugin_pdk::ToolError::unsupported_tool(ctx.tool_name()));
}
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
}
impl Guest for Probe {
fn call(tool_name: String, input_json: String) -> String {
yoi_plugin_pdk::run_json_tool(&tool_name, &input_json, |_ctx, input: serde_json::Value| {
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
})
yoi_plugin_pdk::export_component_tool!(Probe, handle);
fn invocation(input_json: &str) -> exports::yoi::plugin::tool::Invocation {
exports::yoi::plugin::tool::Invocation {
invocation_id: "call-1".into(),
tool_name: "probe".into(),
input_json: input_json.into(),
config_json: "{}".into(),
remaining_ms: 1000,
}
}
#[test]
fn wit_bindgen_generates_current_tool_world() {
let output = <Probe as Guest>::call("probe".to_string(), r#"{"ok":true}"#.to_string());
let value: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["summary"], "probe ok");
fn generated_adapter_returns_record_and_keeps_business_error_in_error_arm() {
use exports::yoi::plugin::tool::Guest;
let output = Probe::invoke(invocation(r#"{"ok":true}"#)).unwrap();
assert_eq!(output.summary, "probe ok");
assert_eq!(output.content, None);
assert_eq!(output.structured_json.as_deref(), Some(r#"{"ok":true}"#));
assert_eq!(
Probe::invoke(invocation("broken")).unwrap_err().code,
"invalid_input"
);
let mut input = invocation("{}");
input.tool_name = "unknown".into();
assert_eq!(Probe::invoke(input).unwrap_err().code, "unsupported_tool");
}
+1 -1
View File
@@ -1847,7 +1847,7 @@ fn parse_plugin_pack_args(
}
fn plugin_usage() -> &'static str {
"usage: yoi plugin new <rust-component-tool|rust-component-service> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]"
"usage: yoi plugin new <rust-component-tool|rust-component-https-tool> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]"
}
fn parse_mcp_args(args: &[String]) -> Result<mcp_cli::McpCliCommand, ParseError> {
+336 -119
View File
@@ -5,9 +5,10 @@ use std::io;
use std::path::{Path, PathBuf};
use manifest::plugin::{
MaterializedPluginPackage, PluginDiagnostic, PluginDiagnosticKind, PluginDiagnosticPhase,
PluginPackageLimits, PluginPermission, PluginSourceKind, PluginTemplateResource,
RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId,
InspectedPluginPackage, MaterializedPluginPackage, PluginConnectionSlot, PluginDiagnostic,
PluginDiagnosticKind, PluginDiagnosticPhase, PluginPackageLimits, PluginRequestedGrant,
PluginRuntimeManifest, PluginSourceKind, PluginTemplateResource,
RUST_COMPONENT_HTTPS_TOOL_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId,
read_plugin_directory, read_plugin_package_file, write_plugin_package_file,
};
use serde::Serialize;
@@ -69,18 +70,16 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result<String> {
let (template_name, resources) = embedded_template_resources(template)?;
materialize_template(destination, resources)?;
let mut next_steps = vec![
"Review plugin.toml and generated Rust source.".to_string(),
"Replace the placeholder plugin.component.wasm with a real built component before offline package verification.".to_string(),
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`.".to_string(),
let core_name = match template_name {
"rust-component-https-tool" => "yoi_rust_component_https_template",
_ => "yoi_rust_component_tool_template",
};
let next_steps = vec![
"Review plugin.toml and generated Rust source. In Cargo.toml, replace the repository-relative yoi-plugin-pdk dependency path with an absolute local crates/plugin-pdk path or a pinned Yoi Git commit.".to_string(),
"In the generated directory, install the wasm32-unknown-unknown Rust target and wasm-tools, then run `cargo build --release --target wasm32-unknown-unknown --target-dir ../guest-target`. Keep build output outside the package input directory so check/pack do not include build files.".to_string(),
format!("Run `wasm-tools component new ../guest-target/wasm32-unknown-unknown/release/{core_name}.wasm -o plugin.component.wasm` to create the actual Component; cargo check alone does not build it."),
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`; static validation does not establish Host implementation or execution support.".to_string(),
];
if template == "rust-component-service" {
next_steps.insert(
1,
"Implement the proposed Service ingress output shape for offline package validation."
.to_string(),
);
}
let report = NewReport {
command: "new",
template: template_name,
@@ -103,9 +102,12 @@ fn embedded_template_resources(
) -> Result<(&'static str, &'static [PluginTemplateResource])> {
match template {
"rust-component-tool" => Ok(("rust-component-tool", RUST_COMPONENT_TOOL_TEMPLATE)),
"rust-component-service" => Ok(("rust-component-service", RUST_COMPONENT_INSTANCE_TEMPLATE)),
"rust-component-https-tool" => Ok((
"rust-component-https-tool",
RUST_COMPONENT_HTTPS_TOOL_TEMPLATE,
)),
_ => Err(format!(
"unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-service)"
"unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-https-tool); Service and other surfaces are not supported"
)
.into()),
}
@@ -195,10 +197,11 @@ fn render_pack(input: &Path, output: Option<&Path>, args: &PluginCliArgs) -> Res
status: "packed",
input_path: input.display().to_string(),
output_path: packed.output_path.display().to_string(),
package: PackageReport::from_materialized(&MaterializedPluginPackage {
package: packed.package,
files: materialized.files,
}),
package: PackageReport::from_package(
&packed.package,
&packed.artifact_digest,
packed.artifact_size,
),
safety: AuthoringSafetyReport::default(),
};
if args.json {
@@ -229,25 +232,19 @@ fn build_check_report(input: &Path) -> CheckReport {
read_plugin_package_file(input, PluginSourceKind::Explicit, &limits)
};
match result {
Ok(materialized) => {
let placeholder_diagnostic = placeholder_component_diagnostic(&materialized);
let status = if placeholder_diagnostic.is_some() {
"partial"
} else {
"verified"
};
let diagnostics = placeholder_diagnostic.into_iter().collect();
CheckReport {
command: "check",
status,
input_path: input.display().to_string(),
input_kind,
package: Some(PackageReport::from_materialized(&materialized)),
diagnostics,
safety: AuthoringSafetyReport::default(),
next_steps: check_next_steps(status),
}
}
Ok(materialized) => CheckReport {
command: "check",
status: "verified",
input_path: input.display().to_string(),
input_kind,
package: Some(PackageReport::from_materialized(&materialized)),
diagnostics: Vec::new(),
safety: AuthoringSafetyReport::default(),
next_steps: vec![
"Package schemas, actual Component imports/exports, and canonical archive metadata are statically valid; no Plugin code was executed.".to_string(),
"Static validation does not establish Host implementation, HTTP availability, installation, or Worker execution support.".to_string(),
],
},
Err(diagnostic) => CheckReport {
command: "check",
status: "rejected",
@@ -263,46 +260,6 @@ fn build_check_report(input: &Path) -> CheckReport {
}
}
fn placeholder_component_diagnostic(
materialized: &MaterializedPluginPackage,
) -> Option<PluginDiagnosticReport> {
let runtime = materialized.package.manifest.runtime.as_ref()?;
let component = runtime.component.as_deref()?;
let component_bytes = materialized.files.get(component)?;
let placeholder_bytes = RUST_COMPONENT_TOOL_TEMPLATE
.iter()
.find(|resource| resource.path == "plugin.component.wasm")?
.contents
.as_bytes();
if component_bytes != placeholder_bytes {
return None;
}
Some(PluginDiagnosticReport {
kind: "placeholder".to_string(),
phase: "runtime".to_string(),
message: format!(
"plugin component runtime artifact `{component}` is the generated placeholder; replace it with a real built component before package verification"
),
})
}
fn check_next_steps(status: &str) -> Vec<String> {
match status {
"verified" => vec![
"Package metadata and archive contents are valid without executing Plugin code."
.to_string(),
"Keep this artifact for explicit offline inspection; Worker Plugin installation is not supported."
.to_string(),
],
"partial" => vec![
"Replace the generated placeholder component artifact with a real built component."
.to_string(),
"Run `yoi plugin check <path>` again to verify the offline package.".to_string(),
],
_ => vec!["Fix the reported diagnostics before packing this Plugin.".to_string()],
}
}
fn plugin_diagnostic_error(context: &str, diagnostic: PluginDiagnostic) -> String {
format!("{context} failed: {}", diagnostic.message)
}
@@ -339,18 +296,19 @@ fn render_check_human(report: &CheckReport) -> Result<String> {
if let Some(package) = &report.package {
writeln!(
out,
"package: {} version={} digest={} entries={} source={} surfaces={} tools={}",
"package: {} version={} package_digest={} entries={} source={} surfaces={} tools={}",
package.reference,
package.version,
package.digest,
package.package_digest,
package.entries.len(),
package.source,
join_or_none(&package.surfaces),
package.tools.len()
)?;
render_artifact_human(&mut out, package)?;
writeln!(
out,
"installation: unsupported; this command only inspects the explicit input path"
"scope: static validation of the explicit input path only; no Host implementation or execution support is established"
)?;
}
if report.diagnostics.is_empty() {
@@ -385,12 +343,13 @@ fn render_pack_human(report: &PackReport) -> Result<String> {
)?;
writeln!(
out,
"package: {} version={} digest={} entries={}",
"package: {} version={} package_digest={} entries={}",
report.package.reference,
report.package.version,
report.package.digest,
report.package.package_digest,
report.package.entries.len()
)?;
render_artifact_human(&mut out, &report.package)?;
writeln!(
out,
"safety: deterministic stored .yoi-plugin archive; no Plugin execution; no config mutation"
@@ -398,6 +357,26 @@ fn render_pack_human(report: &PackReport) -> Result<String> {
Ok(out)
}
fn render_artifact_human(out: &mut String, package: &PackageReport) -> Result<()> {
writeln!(
out,
"canonical archive: artifact_digest={} artifact_size={} bytes",
package.artifact_digest, package.artifact_size
)?;
writeln!(out, "component world: {}", package.component.world)?;
writeln!(
out,
"component imports: {}",
join_or_none(&package.component.imports)
)?;
writeln!(
out,
"component exports: {}",
join_or_none(&package.component.exports)
)?;
Ok(())
}
#[derive(Serialize)]
struct AuthoringSafetyReport {
no_network: bool,
@@ -456,50 +435,75 @@ struct PackageReport {
source: String,
version: String,
schema_version: u32,
digest: String,
package_digest: String,
artifact_digest: String,
artifact_size: u64,
input_path: String,
entries: Vec<String>,
surfaces: Vec<String>,
tools: Vec<String>,
permissions: Vec<String>,
runtime: PluginRuntimeManifest,
component: ComponentReport,
connections: Vec<PluginConnectionSlot>,
requested_grants: Vec<PluginRequestedGrant>,
}
impl PackageReport {
fn from_materialized(materialized: &MaterializedPluginPackage) -> Self {
Self::from_package(
&materialized.package,
&materialized.artifact_digest,
materialized.artifact_size,
)
}
fn from_package(
package: &InspectedPluginPackage,
artifact_digest: &str,
artifact_size: u64,
) -> Self {
Self {
reference: package_reference(&materialized.package.identity),
package: materialized.package.manifest.id.clone(),
source: materialized.package.identity.source.to_string(),
version: materialized.package.manifest.version.clone(),
schema_version: materialized.package.manifest.schema_version,
digest: materialized.package.digest.clone(),
input_path: materialized.package.input_path.display().to_string(),
entries: materialized.package.entries.iter().cloned().collect(),
surfaces: materialized
.package
reference: package_reference(&package.identity),
package: package.manifest.id.clone(),
source: package.identity.source.to_string(),
version: package.manifest.version.clone(),
schema_version: package.manifest.schema_version,
package_digest: package.package_digest.clone(),
artifact_digest: artifact_digest.to_string(),
artifact_size,
input_path: package.input_path.display().to_string(),
entries: package.entries.iter().cloned().collect(),
surfaces: package
.manifest
.surfaces
.iter()
.map(ToString::to_string)
.collect(),
tools: materialized
.package
tools: package
.manifest
.tools
.iter()
.map(|tool| tool.name.clone())
.collect(),
permissions: materialized
.package
.manifest
.permissions
.iter()
.map(|permission| permission_name(permission.clone()).to_string())
.collect(),
runtime: package.manifest.runtime.clone(),
component: ComponentReport {
world: package.component.world.clone(),
imports: package.component.imports.clone(),
exports: package.component.exports.clone(),
},
connections: package.manifest.connections.clone(),
requested_grants: package.manifest.requested_grants.clone(),
}
}
}
#[derive(Serialize)]
struct ComponentReport {
world: String,
imports: Vec<String>,
exports: Vec<String>,
}
#[derive(Serialize)]
struct PluginDiagnosticReport {
kind: String,
@@ -528,10 +532,6 @@ fn package_reference(identity: &SourceQualifiedPluginId) -> String {
identity.to_string()
}
fn permission_name(permission: PluginPermission) -> String {
permission.label()
}
fn join_or_none(values: &[String]) -> String {
if values.is_empty() {
"(none)".to_string()
@@ -571,23 +571,240 @@ mod tests {
use super::*;
use tempfile::tempdir;
fn built_tool_package(destination: &Path) {
render_new(
"rust-component-tool",
destination,
&PluginCliArgs::default(),
)
.unwrap();
fs::write(
destination.join("plugin.component.wasm"),
include_bytes!("../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
)
.unwrap();
}
#[test]
fn explicit_template_check_and_pack_remain_offline_authoring_operations() {
fn tool_templates_explain_actual_component_build_without_shipping_a_placeholder() {
let dir = tempdir().unwrap();
for template in ["rust-component-tool", "rust-component-https-tool"] {
let destination = dir.path().join(template);
let rendered =
render_new(template, &destination, &PluginCliArgs { json: true }).unwrap();
let report: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(report["template"], template);
assert!(destination.join("plugin.toml").is_file());
assert!(destination.join("src/lib.rs").is_file());
assert!(!destination.join("plugin.component.wasm").exists());
let steps = report["next_steps"].to_string();
assert!(steps.contains("yoi-plugin-pdk"));
assert!(steps.contains(
"cargo build --release --target wasm32-unknown-unknown --target-dir ../guest-target"
));
assert!(steps.contains("wasm-tools component new"));
let cargo: toml::Value =
toml::from_str(&fs::read_to_string(destination.join("Cargo.toml")).unwrap())
.unwrap();
let core_name = cargo["package"]["name"].as_str().unwrap().replace('-', "_");
assert!(steps.contains(&format!(
"wasm-tools component new ../guest-target/wasm32-unknown-unknown/release/{core_name}.wasm -o plugin.component.wasm"
)));
assert!(steps.contains("does not establish Host implementation"));
assert_eq!(report["safety"]["no_plugin_execution"], true);
}
}
#[test]
fn unsupported_templates_are_rejected_before_creating_a_destination() {
let dir = tempdir().unwrap();
let destination = dir.path().join("unsupported");
for template in [
"rust-component-service",
"rust-component-instance",
"unknown",
] {
let error = render_new(template, &destination, &PluginCliArgs::default()).unwrap_err();
assert!(error.to_string().contains("unsupported plugin template"));
assert!(!destination.exists());
}
}
#[test]
fn unbuilt_and_noncomponent_templates_are_rejected_not_partially_verified_or_packed() {
let dir = tempdir().unwrap();
let source = dir.path().join("unbuilt");
let output = dir.path().join("rejected.yoi-plugin");
let args = PluginCliArgs::default();
render_new("rust-component-tool", &source, &args).unwrap();
let missing = build_check_report(&source);
assert_eq!(missing.status, "rejected");
assert!(missing.package.is_none());
assert!(!missing.diagnostics.is_empty());
assert!(render_pack(&source, Some(&output), &args).is_err());
assert!(!output.exists());
for bytes in [b"placeholder".as_slice(), b"\0asm\x01\0\0\0".as_slice()] {
fs::write(source.join("plugin.component.wasm"), bytes).unwrap();
let rejected = build_check_report(&source);
assert_eq!(rejected.status, "rejected");
assert!(rejected.package.is_none());
assert!(!rejected.diagnostics.is_empty());
let human = render_check_report(&rejected, &args).unwrap();
assert!(human.contains("[rejected]"));
assert!(human.contains("diagnostics:"));
assert!(render_pack(&source, Some(&output), &args).is_err());
assert!(!output.exists());
}
}
#[test]
fn actual_component_check_pack_and_readback_report_both_digests_and_canonical_size() {
let dir = tempdir().unwrap();
let source = dir.path().join("example");
let output = dir.path().join("example.yoi-plugin");
let args = PluginCliArgs::default();
let second_output = dir.path().join("second.yoi-plugin");
let args = PluginCliArgs { json: true };
built_tool_package(&source);
let created = render_new("rust-component-tool", &source, &args).unwrap();
assert!(created.contains("created plugin template"));
let checked: serde_json::Value =
serde_json::from_str(&render_check(&source, &args).unwrap()).unwrap();
assert_eq!(checked["status"], "verified");
assert_eq!(checked["package"]["schema_version"], 2);
assert_eq!(checked["safety"]["no_network"], true);
assert_eq!(checked["safety"]["no_plugin_execution"], true);
assert!(checked["package"].get("digest").is_none());
assert!(checked["package"].get("permissions").is_none());
assert_eq!(
checked["package"]["component"]["imports"],
serde_json::json!([])
);
assert_eq!(
checked["package"]["component"]["exports"],
serde_json::json!(["yoi:plugin/tool@2.0.0"])
);
assert_eq!(
checked["package"]["runtime"]["world"],
"yoi:plugin/tool-plugin@2.0.0"
);
assert_ne!(
checked["package"]["package_digest"],
checked["package"]["artifact_digest"]
);
for field in ["package_digest", "artifact_digest"] {
assert!(
checked["package"][field]
.as_str()
.unwrap()
.starts_with("sha256:")
);
}
let checked = render_check(&source, &args).unwrap();
assert!(checked.contains("[partial]"));
assert!(checked.contains("explicit input path"));
let packed: serde_json::Value =
serde_json::from_str(&render_pack(&source, Some(&output), &args).unwrap()).unwrap();
assert_eq!(packed["status"], "packed");
let readback: serde_json::Value =
serde_json::from_str(&render_check(&output, &args).unwrap()).unwrap();
assert_eq!(readback["status"], "verified");
for field in [
"package_digest",
"artifact_digest",
"artifact_size",
"component",
] {
assert_eq!(checked["package"][field], packed["package"][field]);
assert_eq!(checked["package"][field], readback["package"][field]);
}
assert_eq!(
packed["package"]["artifact_size"].as_u64().unwrap(),
fs::metadata(&output).unwrap().len()
);
render_pack(&source, Some(&second_output), &args).unwrap();
assert_eq!(
fs::read(&output).unwrap(),
fs::read(&second_output).unwrap()
);
let packed = render_pack(&source, Some(&output), &args).unwrap();
assert!(packed.contains("[packed]"));
assert!(output.is_file());
let human = render_check(&output, &PluginCliArgs::default()).unwrap();
assert!(human.contains("package_digest=sha256:"));
assert!(human.contains("artifact_digest=sha256:"));
assert!(human.contains("artifact_size="));
assert!(human.contains("static validation of the explicit input path only"));
let human_pack = render_pack(&source, Some(&output), &PluginCliArgs::default()).unwrap();
assert!(human_pack.contains("package_digest=sha256:"));
assert!(human_pack.contains("artifact_digest=sha256:"));
assert!(human_pack.contains("artifact_size="));
}
#[test]
fn https_component_reports_static_requests_not_host_execution_support() {
let dir = tempdir().unwrap();
let source = dir.path().join("https");
let output = dir.path().join("https.yoi-plugin");
let args = PluginCliArgs { json: true };
render_new("rust-component-https-tool", &source, &args).unwrap();
fs::write(
source.join("plugin.component.wasm"),
include_bytes!("../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"),
)
.unwrap();
let report: serde_json::Value =
serde_json::from_str(&render_check(&source, &args).unwrap()).unwrap();
assert_eq!(report["status"], "verified");
assert_eq!(
report["package"]["component"]["world"],
"yoi:plugin/https-tool-plugin@2.0.0"
);
assert_eq!(
report["package"]["component"]["imports"],
serde_json::json!([
"yoi:host/connections@2.0.0",
"yoi:host/errors@2.0.0",
"yoi:host/http@2.0.0"
])
);
assert_eq!(report["package"]["connections"][0]["name"], "api");
assert_eq!(
report["package"]["requested_grants"],
serde_json::json!([
{ "kind": "https", "slot": "api" }
])
);
assert!(
report["next_steps"]
.to_string()
.contains("does not establish Host implementation, HTTP availability")
);
assert_eq!(report["safety"]["no_network"], true);
assert_eq!(report["safety"]["no_plugin_execution"], true);
render_pack(&source, Some(&output), &args).unwrap();
assert_eq!(build_check_report(&output).status, "verified");
}
#[test]
fn unsupported_surface_and_legacy_schema_diagnostics_reach_cli_reports() {
let dir = tempdir().unwrap();
let source = dir.path().join("invalid");
built_tool_package(&source);
let manifest_path = source.join("plugin.toml");
let manifest = fs::read_to_string(&manifest_path).unwrap();
for invalid in [
manifest.replace("schema_version = 2", "schema_version = 1"),
manifest.replace("surfaces = [\"tool\"]", "surfaces = [\"service\"]"),
] {
assert_ne!(manifest, invalid);
fs::write(&manifest_path, invalid).unwrap();
let rendered = render_check(&source, &PluginCliArgs { json: true }).unwrap();
let report: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(report["status"], "rejected");
assert!(report["package"].is_null());
assert!(
!report["diagnostics"][0]["message"]
.as_str()
.unwrap()
.is_empty()
);
}
}
#[test]