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:
@@ -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 }
|
||||
|
||||
+736
-488
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user