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