yoi/crates/pod/src/feature/plugin.rs

4702 lines
164 KiB
Rust

//! Plugin package contributions for model-visible Tool schemas.
//!
//! This module registers *enabled* plugin package tool surface definitions and
//! executes Tool calls through the minimal sandboxed `yoi-plugin-wasm-1` WASM
//! ABI. It deliberately does not grant filesystem, environment, hook, service,
//! ingress, or ambient network authority. WASM Tools can only reach outbound HTTPS
//! through the explicit `yoi:https` host import, and filesystem read/list/write
//! through the explicit `yoi:fs` host import, with matching permissions and
//! scoped allowlist grants.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{Read as _, Write as _};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use async_trait::async_trait;
use llm_worker::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
};
use manifest::plugin::{
PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, PLUGIN_RUNTIME_WASM_ABI,
PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, PluginFsGrant,
PluginFsOperation, PluginHostApi, PluginPermission, PluginSurface, PluginToolManifest,
ResolvedPluginRecord, read_resolved_plugin_runtime_component,
read_resolved_plugin_runtime_module,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::{
FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule,
FeatureRuntimeKind, ToolContribution, ToolDeclaration,
};
/// Build Feature modules for enabled plugin packages when the profile exposes
/// the plugin Tool surface feature.
pub fn plugin_tool_features_if_enabled(
feature_enabled: bool,
config: &PluginConfig,
) -> Vec<PluginToolFeature> {
if !feature_enabled {
return Vec::new();
}
plugin_tool_features(config)
}
/// Build Feature modules for enabled plugin packages that declare Tool surfaces.
pub fn plugin_tool_features(config: &PluginConfig) -> Vec<PluginToolFeature> {
config
.resolved
.iter()
.filter(|record| record.enabled_surfaces.contains(&PluginSurface::Tool))
.filter(|record| !record.manifest.tools.is_empty())
.cloned()
.map(PluginToolFeature::new)
.collect()
}
#[derive(Clone, Debug)]
pub struct PluginToolFeature {
record: ResolvedPluginRecord,
feature_id: FeatureId,
}
impl PluginToolFeature {
pub fn new(record: ResolvedPluginRecord) -> Self {
let feature_id = FeatureId::new(format!("plugin:{}:tool", record.identity))
.expect("source-qualified plugin identity yields non-empty feature id");
Self { record, feature_id }
}
pub fn origin(&self) -> ToolOrigin {
ToolOrigin {
kind: "plugin".into(),
plugin_id: self.record.manifest.id.clone(),
plugin_ref: self.record.identity.to_string(),
source: self.record.identity.source.to_string(),
digest: self.record.digest.clone(),
package_version: self.record.version.clone(),
package_api_version: self.record.manifest.schema_version,
surface: "tool".into(),
}
}
}
/// Static, read-only eligibility information for a resolved plugin package.
///
/// This inspection mirrors the registration-time permission checks without
/// loading the WASM module, calling a plugin Tool, or executing plugin code.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginStaticInspection {
pub runtime: PluginRuntimeEligibility,
pub host_apis: Vec<PluginPermissionEligibility>,
pub tools: Vec<PluginToolEligibility>,
}
impl PluginStaticInspection {
pub fn statically_eligible(&self) -> bool {
self.runtime.eligible
&& self.host_apis.iter().all(|api| api.eligible)
&& self.tools.iter().all(|tool| tool.eligible)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginRuntimeEligibility {
pub eligible: bool,
pub status: String,
pub diagnostic: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginPermissionEligibility {
pub permission: String,
pub requested: bool,
pub granted: bool,
pub eligible: bool,
pub diagnostic: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginToolEligibility {
pub name: String,
pub permission: String,
pub requested: bool,
pub granted: bool,
pub eligible: bool,
pub external_write: bool,
pub diagnostic: Option<String>,
}
/// Inspect static plugin runtime/tool eligibility without executing plugin code.
pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginStaticInspection {
let runtime = match &record.manifest.runtime {
Some(runtime)
if runtime.kind == PLUGIN_RUNTIME_WASM_KIND
&& runtime.abi.as_deref() == Some(PLUGIN_RUNTIME_WASM_ABI)
&& runtime.entry.is_some() =>
{
PluginRuntimeEligibility {
eligible: true,
status: format!("{PLUGIN_RUNTIME_WASM_KIND}/{PLUGIN_RUNTIME_WASM_ABI}"),
diagnostic: None,
}
}
Some(runtime) if runtime.kind == PLUGIN_RUNTIME_WASM_KIND => {
let status = runtime
.abi
.as_deref()
.map(|abi| format!("{PLUGIN_RUNTIME_WASM_KIND}/{abi}"))
.unwrap_or_else(|| format!("{PLUGIN_RUNTIME_WASM_KIND}/<missing-abi>"));
PluginRuntimeEligibility {
eligible: false,
status,
diagnostic: Some("unsupported or missing plugin runtime ABI".to_string()),
}
}
Some(runtime)
if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND
&& runtime.world.as_deref() == Some(PLUGIN_COMPONENT_TOOL_WORLD)
&& runtime.component.is_some() =>
{
PluginRuntimeEligibility {
eligible: true,
status: format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}"),
diagnostic: None,
}
}
Some(runtime) if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND => {
let status = runtime
.world
.as_deref()
.map(|world| format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{world}"))
.unwrap_or_else(|| format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/<missing-world>"));
PluginRuntimeEligibility {
eligible: false,
status,
diagnostic: Some("unsupported or missing plugin component world".to_string()),
}
}
Some(runtime) => PluginRuntimeEligibility {
eligible: false,
status: runtime.kind.clone(),
diagnostic: Some(format!(
"unsupported plugin runtime kind `{}`",
runtime.kind
)),
},
None => PluginRuntimeEligibility {
eligible: false,
status: "none".to_string(),
diagnostic: Some("plugin runtime is not declared".to_string()),
},
};
let host_apis = [PluginHostApi::Https, PluginHostApi::Fs]
.into_iter()
.filter_map(|api| {
let permission = PluginPermission::host_api(api);
let requested = permission_requested(record, &permission);
let granted = grant_allows(record, &permission);
if !requested && !granted {
return None;
}
let diagnostic = authorize_plugin_host_api(record, api)
.err()
.map(|error| error.bounded_message());
Some(PluginPermissionEligibility {
permission: permission.label(),
requested,
granted,
eligible: diagnostic.is_none(),
diagnostic,
})
})
.collect();
let duplicate_tool_names = duplicate_tool_names(record);
let tools = record
.manifest
.tools
.iter()
.map(|tool| {
let permission = PluginPermission::tool(&tool.name);
let requested = permission_requested(record, &permission);
let granted = grant_allows(record, &permission);
let mut diagnostics = validate_plugin_tool_definition(tool, &duplicate_tool_names);
if let Err(error) = authorize_plugin_tool(record, tool) {
diagnostics.push(error.bounded_message());
}
let diagnostic = join_tool_diagnostics(diagnostics);
PluginToolEligibility {
name: tool.name.clone(),
permission: permission.label(),
requested,
granted,
eligible: diagnostic.is_none(),
external_write: tool.external_write,
diagnostic,
}
})
.collect();
PluginStaticInspection {
runtime,
host_apis,
tools,
}
}
fn permission_requested(record: &ResolvedPluginRecord, permission: &PluginPermission) -> bool {
record
.manifest
.permissions
.iter()
.any(|requested| requested == permission)
}
fn grant_allows(record: &ResolvedPluginRecord, permission: &PluginPermission) -> bool {
record
.grants
.permissions
.iter()
.any(|granted| granted == permission)
}
fn duplicate_tool_names(record: &ResolvedPluginRecord) -> HashSet<String> {
let mut seen = HashSet::new();
let mut duplicates = HashSet::new();
for tool in &record.manifest.tools {
if !seen.insert(tool.name.clone()) {
duplicates.insert(tool.name.clone());
}
}
duplicates
}
fn validate_plugin_tool_definition(
tool: &PluginToolManifest,
duplicate_tool_names: &HashSet<String>,
) -> Vec<String> {
let mut diagnostics = Vec::new();
if duplicate_tool_names.contains(&tool.name) {
diagnostics.push(format!(
"tool `{}` has duplicate name within plugin manifest",
tool.name
));
}
if let Err(reason) = validate_tool_name(&tool.name) {
diagnostics.push(format!("tool `{}` has invalid name: {reason}", tool.name));
}
if let Err(reason) = validate_input_schema(&tool.input_schema) {
diagnostics.push(format!(
"tool `{}` has invalid input_schema: {reason}",
tool.name
));
}
diagnostics
}
fn join_tool_diagnostics(diagnostics: Vec<String>) -> Option<String> {
if diagnostics.is_empty() {
None
} else {
Some(bounded_message(diagnostics.join("; ")))
}
}
impl FeatureModule for PluginToolFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor =
FeatureDescriptor {
id: self.feature_id.clone(),
runtime: FeatureRuntimeKind::ExternalPlugin,
display_name: self.record.manifest.name.clone(),
version: self.record.manifest.version.clone(),
description: self.record.manifest.description.clone().unwrap_or_else(|| {
format!("Plugin tool surface from {}", self.record.identity)
}),
tools: Vec::new(),
hooks: Vec::new(),
background_tasks: Vec::new(),
provides_services: Vec::new(),
requires_services: Vec::new(),
protocol_providers: Vec::new(),
};
for tool in &self.record.manifest.tools {
descriptor = descriptor.with_tool(ToolDeclaration::new(
tool.name.clone(),
tool.description.clone(),
));
}
descriptor
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
validate_declared_tool_names(&self.record)?;
let origin = self.origin();
let mut registered = 0usize;
let mut denied = Vec::new();
for tool in &self.record.manifest.tools {
validate_tool_name(&tool.name).map_err(|reason| {
FeatureInstallError::Install(format!(
"plugin `{}` tool `{}` has invalid name: {reason}",
self.record.identity, tool.name
))
})?;
validate_input_schema(&tool.input_schema).map_err(|reason| {
FeatureInstallError::Install(format!(
"plugin `{}` tool `{}` has invalid input_schema: {reason}",
self.record.identity, tool.name
))
})?;
if let Err(error) = authorize_plugin_tool(&self.record, tool) {
let message = format!(
"plugin `{}` tool `{}` registration denied: {}",
self.record.identity,
tool.name,
error.bounded_message()
);
context.diagnostics().warning(message.clone());
denied.push(message);
continue;
}
context.tools().register(ToolContribution::new(
tool.name.clone(),
plugin_wasm_tool_definition(
self.record.clone(),
tool.name.clone(),
tool.description.clone(),
tool.input_schema.clone(),
origin.clone(),
),
))?;
registered += 1;
}
if registered == 0 && !denied.is_empty() {
let summary = if denied.len() == 1 {
denied.remove(0)
} else {
format!(
"{} plugin tool registrations denied; first denial: {}",
denied.len(),
denied[0]
)
};
return Err(FeatureInstallError::Install(bounded_message(summary)));
}
Ok(())
}
}
impl PluginHttpsClient for ReqwestPluginHttpsClient {
fn execute(
&self,
request: &PluginHttpsRequest,
url: &reqwest::Url,
limits: PluginHttpsLimits,
) -> Result<PluginHttpsResponse, PluginHttpsError> {
let pinned_resolution = resolve_https_target_for_client(url, &SystemPluginHttpsResolver)?;
let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|_| {
PluginHttpsError::new(format!("unsupported HTTPS method `{}`", request.method))
})?;
let mut client_builder = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(limits.timeout)
.no_proxy()
.user_agent("yoi-plugin-https-host-api/0.1");
if let Some(pinned_resolution) = &pinned_resolution {
for domain in &pinned_resolution.domains {
client_builder = client_builder.resolve_to_addrs(domain, &pinned_resolution.addrs);
}
}
let client = client_builder.build().map_err(|error| {
PluginHttpsError::new(format!("HTTPS client build failed: {error}"))
})?;
let mut builder = client.request(method, url.clone()).timeout(limits.timeout);
for header in &request.headers {
let name =
reqwest::header::HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| {
PluginHttpsError::new(format!(
"invalid HTTPS request header name `{}`",
header.name
))
})?;
let value = reqwest::header::HeaderValue::from_str(&header.value).map_err(|_| {
PluginHttpsError::new(format!(
"invalid HTTPS request header value for `{}`",
header.name
))
})?;
builder = builder.header(name, value);
}
if let Some(body) = &request.body {
builder = builder.body(body.clone());
}
let mut response = builder.send().map_err(|error| {
if error.is_timeout() {
PluginHttpsError::new(format!("HTTPS request to {} timed out", safe_url(url)))
} else {
PluginHttpsError::new(format!(
"HTTPS request to {} failed: {error}",
safe_url(url)
))
}
})?;
let status = response.status().as_u16();
let headers = collect_https_response_headers(response.headers());
let mut body = Vec::new();
let read_limit = limits.max_response_bytes.saturating_add(1) as u64;
response
.by_ref()
.take(read_limit)
.read_to_end(&mut body)
.map_err(|error| {
PluginHttpsError::new(format!("HTTPS response read failed: {error}"))
})?;
let truncated = body.len() > limits.max_response_bytes;
if truncated {
body.truncate(limits.max_response_bytes);
}
Ok(PluginHttpsResponse {
status,
headers,
body: String::from_utf8_lossy(&body).into_owned(),
truncated,
})
}
}
fn execute_plugin_https_request(
record: &ResolvedPluginRecord,
client: &dyn PluginHttpsClient,
request_bytes: &[u8],
) -> Result<Vec<u8>, PluginHttpsError> {
if request_bytes.len() > PLUGIN_HTTPS_MAX_REQUEST_BYTES {
return Err(PluginHttpsError::new(format!(
"HTTPS request descriptor exceeds {} bytes",
PLUGIN_HTTPS_MAX_REQUEST_BYTES
)));
}
authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| {
PluginHttpsError::new(format!(
"plugin host API dispatch denied: {}",
error.bounded_message()
))
})?;
let request: PluginHttpsRequest = serde_json::from_slice(request_bytes)
.map_err(|error| PluginHttpsError::new(format!("invalid HTTPS request JSON: {error}")))?;
let url = validate_plugin_https_request(record, &request)?;
let mut response = client.execute(&request, &url, PluginHttpsLimits::default())?;
enforce_https_response_bounds(&mut response, PluginHttpsLimits::default());
serde_json::to_vec(&response)
.map_err(|error| PluginHttpsError::new(format!("failed to encode HTTPS response: {error}")))
}
fn execute_plugin_fs_request(
record: &ResolvedPluginRecord,
operation: PluginFsRuntimeOperation,
request_bytes: &[u8],
) -> Result<Vec<u8>, PluginFsError> {
if request_bytes.len() > PLUGIN_FS_MAX_REQUEST_BYTES {
return Err(PluginFsError::new(format!(
"FS request descriptor exceeds {} bytes",
PLUGIN_FS_MAX_REQUEST_BYTES
)));
}
authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| {
PluginFsError::new(format!(
"plugin host API dispatch denied: {}",
error.bounded_message()
))
})?;
match operation {
PluginFsRuntimeOperation::Read => {
let request: PluginFsPathRequest =
serde_json::from_slice(request_bytes).map_err(|error| {
PluginFsError::new(format!("invalid FS read request JSON: {error}"))
})?;
execute_plugin_fs_read(record, &request.path)
}
PluginFsRuntimeOperation::List => {
let request: PluginFsPathRequest =
serde_json::from_slice(request_bytes).map_err(|error| {
PluginFsError::new(format!("invalid FS list request JSON: {error}"))
})?;
execute_plugin_fs_list(record, &request.path)
}
PluginFsRuntimeOperation::Write => {
let request: PluginFsWriteRequest =
serde_json::from_slice(request_bytes).map_err(|error| {
PluginFsError::new(format!("invalid FS write request JSON: {error}"))
})?;
execute_plugin_fs_write(record, &request.path, request.content.as_bytes())
}
}
}
fn execute_plugin_fs_read(
record: &ResolvedPluginRecord,
path: &str,
) -> Result<Vec<u8>, PluginFsError> {
let target = authorize_fs_path(record, PluginFsRuntimeOperation::Read, path)?;
let meta = fs::metadata(&target.resolved).map_err(|error| {
PluginFsError::new(format!(
"FS read metadata failed for {}: {error}",
safe_fs_path(path)
))
})?;
if !meta.is_file() {
return Err(PluginFsError::new(format!(
"FS read target is not a regular file: {}",
safe_fs_path(path)
)));
}
let mut file = fs::File::open(&target.resolved).map_err(|error| {
PluginFsError::new(format!(
"FS read failed for {}: {error}",
safe_fs_path(path)
))
})?;
let mut bytes = Vec::new();
std::io::Read::by_ref(&mut file)
.take((PLUGIN_FS_MAX_READ_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|error| {
PluginFsError::new(format!(
"FS read failed for {}: {error}",
safe_fs_path(path)
))
})?;
let truncated = bytes.len() > PLUGIN_FS_MAX_READ_BYTES;
if truncated {
bytes.truncate(PLUGIN_FS_MAX_READ_BYTES);
}
let response = PluginFsReadResponse {
path: safe_fs_path(path),
content: String::from_utf8_lossy(&bytes).into_owned(),
truncated,
};
serde_json::to_vec(&response)
.map_err(|error| PluginFsError::new(format!("failed to encode FS read response: {error}")))
}
fn execute_plugin_fs_list(
record: &ResolvedPluginRecord,
path: &str,
) -> Result<Vec<u8>, PluginFsError> {
let target = authorize_fs_path(record, PluginFsRuntimeOperation::List, path)?;
let meta = fs::metadata(&target.resolved).map_err(|error| {
PluginFsError::new(format!(
"FS list metadata failed for {}: {error}",
safe_fs_path(path)
))
})?;
if !meta.is_dir() {
return Err(PluginFsError::new(format!(
"FS list target is not a directory: {}",
safe_fs_path(path)
)));
}
let mut entries = Vec::new();
let mut truncated = false;
for entry in fs::read_dir(&target.resolved).map_err(|error| {
PluginFsError::new(format!(
"FS list failed for {}: {error}",
safe_fs_path(path)
))
})? {
let entry = entry.map_err(|error| {
PluginFsError::new(format!(
"FS list failed for {}: {error}",
safe_fs_path(path)
))
})?;
if entries.len() >= PLUGIN_FS_MAX_LIST_ENTRIES {
truncated = true;
break;
}
let name = entry.file_name().to_string_lossy().into_owned();
let file_type = entry.file_type().map_err(|error| {
PluginFsError::new(format!(
"FS list failed for {}: {error}",
safe_fs_path(path)
))
})?;
let kind = if file_type.is_dir() {
"dir"
} else if file_type.is_file() {
"file"
} else if file_type.is_symlink() {
"symlink"
} else {
"other"
};
entries.push(PluginFsDirEntry {
name,
kind: kind.to_string(),
});
}
entries.sort_by(|left, right| left.name.cmp(&right.name));
let response = PluginFsListResponse {
path: safe_fs_path(path),
entries,
truncated,
};
serde_json::to_vec(&response)
.map_err(|error| PluginFsError::new(format!("failed to encode FS list response: {error}")))
}
fn execute_plugin_fs_write(
record: &ResolvedPluginRecord,
path: &str,
content: &[u8],
) -> Result<Vec<u8>, PluginFsError> {
if content.len() > PLUGIN_FS_MAX_WRITE_BYTES {
return Err(PluginFsError::new(format!(
"FS write content exceeds {} bytes",
PLUGIN_FS_MAX_WRITE_BYTES
)));
}
let target = authorize_fs_write_path(record, path)?;
let lock = plugin_fs_write_lock(target.lock_key.clone());
let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.custom_flags(libc::O_NOFOLLOW);
}
let mut file = options.open(&target.resolved).map_err(|error| {
PluginFsError::new(format!(
"FS write failed for {}: {error}",
safe_fs_path(path)
))
})?;
file.write_all(content).map_err(|error| {
PluginFsError::new(format!(
"FS write failed for {}: {error}",
safe_fs_path(path)
))
})?;
file.sync_all().map_err(|error| {
PluginFsError::new(format!(
"FS write failed for {}: {error}",
safe_fs_path(path)
))
})?;
let response = PluginFsWriteResponse {
path: safe_fs_path(path),
bytes_written: content.len(),
};
serde_json::to_vec(&response)
.map_err(|error| PluginFsError::new(format!("failed to encode FS write response: {error}")))
}
fn enforce_https_response_bounds(response: &mut PluginHttpsResponse, limits: PluginHttpsLimits) {
if response.body.len() > limits.max_response_bytes {
truncate_string_to_boundary(&mut response.body, limits.max_response_bytes);
response.truncated = true;
}
if response.headers.len() > PLUGIN_HTTPS_MAX_RESPONSE_HEADERS {
response.headers.truncate(PLUGIN_HTTPS_MAX_RESPONSE_HEADERS);
}
for header in &mut response.headers {
header.value = bounded_header_value(&header.value);
}
}
fn truncate_string_to_boundary(value: &mut String, max_len: usize) {
if value.len() <= max_len {
return;
}
let mut boundary = max_len;
while boundary > 0 && !value.is_char_boundary(boundary) {
boundary -= 1;
}
value.truncate(boundary);
}
#[derive(Clone, Debug)]
struct PluginFsResolvedPath {
resolved: PathBuf,
}
#[derive(Clone, Debug)]
struct PluginFsWritePath {
resolved: PathBuf,
lock_key: PathBuf,
}
fn authorize_fs_path(
record: &ResolvedPluginRecord,
operation: PluginFsRuntimeOperation,
path: &str,
) -> Result<PluginFsResolvedPath, PluginFsError> {
let relative = parse_plugin_fs_relative_path(path)?;
let mut saw_operation_grant = false;
let mut last_error: Option<PluginFsError> = None;
for grant in &record.grants.fs {
if !grant_allows_operation(grant, operation) {
continue;
}
saw_operation_grant = true;
match resolve_plugin_fs_grant_root(grant)
.and_then(|root| resolve_existing_plugin_fs_path(&root, &relative, safe_fs_path(path)))
{
Ok(resolved) => return Ok(PluginFsResolvedPath { resolved }),
Err(error) => last_error = Some(error),
}
}
if !saw_operation_grant {
return Err(PluginFsError::new(format!(
"host_api.fs {} denied: no matching operation grant",
operation.as_str()
)));
}
Err(last_error.unwrap_or_else(|| {
PluginFsError::new(format!(
"host_api.fs {} denied by scoped path policy",
operation.as_str()
))
}))
}
fn authorize_fs_write_path(
record: &ResolvedPluginRecord,
path: &str,
) -> Result<PluginFsWritePath, PluginFsError> {
let relative = parse_plugin_fs_relative_path(path)?;
if relative.as_os_str().is_empty() || relative.file_name().is_none() {
return Err(PluginFsError::new("FS write path must name a file"));
}
let mut saw_operation_grant = false;
let mut last_error: Option<PluginFsError> = None;
for grant in &record.grants.fs {
if !grant_allows_operation(grant, PluginFsRuntimeOperation::Write) {
continue;
}
saw_operation_grant = true;
match resolve_plugin_fs_grant_root(grant)
.and_then(|root| resolve_writable_plugin_fs_path(&root, &relative, safe_fs_path(path)))
{
Ok(resolved) => return Ok(resolved),
Err(error) => last_error = Some(error),
}
}
if !saw_operation_grant {
return Err(PluginFsError::new(
"host_api.fs write denied: no matching operation grant",
));
}
Err(last_error
.unwrap_or_else(|| PluginFsError::new("host_api.fs write denied by scoped path policy")))
}
fn grant_allows_operation(grant: &PluginFsGrant, operation: PluginFsRuntimeOperation) -> bool {
let requested = operation.grant_operation();
grant.operations.iter().any(|allowed| *allowed == requested)
}
fn parse_plugin_fs_relative_path(path: &str) -> Result<PathBuf, PluginFsError> {
if path.as_bytes().len() > PLUGIN_FS_MAX_PATH_BYTES {
return Err(PluginFsError::new(format!(
"FS path exceeds {} bytes",
PLUGIN_FS_MAX_PATH_BYTES
)));
}
if path.is_empty() {
return Err(PluginFsError::new("FS path must not be empty"));
}
if path.contains('\0') {
return Err(PluginFsError::new("FS path contains a NUL byte"));
}
let input = Path::new(path);
if input.is_absolute() {
return Err(PluginFsError::new(
"FS request paths must be relative to a configured fs grant root",
));
}
let mut relative = PathBuf::new();
for component in input.components() {
match component {
Component::Normal(component) => relative.push(component),
Component::CurDir => {}
Component::ParentDir => {
return Err(PluginFsError::new(
"FS path traversal outside the grant root is denied",
));
}
Component::RootDir | Component::Prefix(_) => {
return Err(PluginFsError::new(
"FS request paths must be relative to a configured fs grant root",
));
}
}
}
Ok(relative)
}
fn resolve_plugin_fs_grant_root(grant: &PluginFsGrant) -> Result<PathBuf, PluginFsError> {
let root = grant.root.trim();
if root.is_empty() || root.as_bytes().len() > PLUGIN_FS_MAX_PATH_BYTES {
return Err(PluginFsError::new("configured fs grant root is invalid"));
}
let root_path = Path::new(root);
if !root_path.is_absolute() {
return Err(PluginFsError::new(
"configured fs grant root must be an absolute path",
));
}
let root_meta = fs::symlink_metadata(root_path)
.map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?;
if root_meta.file_type().is_symlink() {
return Err(PluginFsError::new(
"configured fs grant root must not be a symlink",
));
}
let canonical = fs::canonicalize(root_path)
.map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?;
let metadata = fs::metadata(&canonical)
.map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?;
if !metadata.is_dir() {
return Err(PluginFsError::new(
"configured fs grant root must be a directory",
));
}
Ok(canonical)
}
fn resolve_existing_plugin_fs_path(
root: &Path,
relative: &Path,
requested: String,
) -> Result<PathBuf, PluginFsError> {
let target = root.join(relative);
reject_symlink_components(root, relative, true, &requested)?;
let canonical = fs::canonicalize(&target).map_err(|error| {
PluginFsError::new(format!("FS path is unavailable for {requested}: {error}"))
})?;
if !canonical.starts_with(root) {
return Err(PluginFsError::new(format!(
"FS path escapes the configured grant root: {requested}"
)));
}
Ok(canonical)
}
fn resolve_writable_plugin_fs_path(
root: &Path,
relative: &Path,
requested: String,
) -> Result<PluginFsWritePath, PluginFsError> {
let Some(file_name) = relative.file_name() else {
return Err(PluginFsError::new("FS write path must name a file"));
};
let parent_relative = relative.parent().unwrap_or_else(|| Path::new(""));
reject_symlink_components(root, parent_relative, true, &requested)?;
let parent = fs::canonicalize(root.join(parent_relative)).map_err(|error| {
PluginFsError::new(format!(
"FS write parent is unavailable for {requested}: {error}"
))
})?;
if !parent.starts_with(root) {
return Err(PluginFsError::new(format!(
"FS write parent escapes the configured grant root: {requested}"
)));
}
let resolved = parent.join(file_name);
if let Ok(meta) = fs::symlink_metadata(&resolved) {
if meta.file_type().is_symlink() {
return Err(PluginFsError::new(format!(
"FS write target is a symlink: {requested}"
)));
}
if meta.is_dir() {
return Err(PluginFsError::new(format!(
"FS write target is a directory: {requested}"
)));
}
let canonical = fs::canonicalize(&resolved).map_err(|error| {
PluginFsError::new(format!(
"FS write target is unavailable for {requested}: {error}"
))
})?;
if !canonical.starts_with(root) {
return Err(PluginFsError::new(format!(
"FS write target escapes the configured grant root: {requested}"
)));
}
return Ok(PluginFsWritePath {
resolved,
lock_key: canonical,
});
}
if !resolved.starts_with(root) {
return Err(PluginFsError::new(format!(
"FS write target escapes the configured grant root: {requested}"
)));
}
Ok(PluginFsWritePath {
lock_key: resolved.clone(),
resolved,
})
}
fn reject_symlink_components(
root: &Path,
relative: &Path,
include_leaf: bool,
requested: &str,
) -> Result<(), PluginFsError> {
let mut current = root.to_path_buf();
let components = relative.components().collect::<Vec<_>>();
for (index, component) in components.iter().enumerate() {
let Component::Normal(name) = component else {
continue;
};
current.push(name);
if !include_leaf && index + 1 == components.len() {
break;
}
let metadata = fs::symlink_metadata(&current).map_err(|error| {
PluginFsError::new(format!(
"FS path component is unavailable for {requested}: {error}"
))
})?;
if metadata.file_type().is_symlink() {
return Err(PluginFsError::new(format!(
"FS path symlink escape is denied: {requested}"
)));
}
}
Ok(())
}
fn plugin_fs_write_lock(path: PathBuf) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
let map = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
let mut map = map.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
map.entry(path)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
fn safe_fs_path(path: &str) -> String {
let mut sanitized = redact_secret_like(path).replace('\0', " ");
if sanitized.len() > 160 {
let mut boundary = 160;
while boundary > 0 && !sanitized.is_char_boundary(boundary) {
boundary -= 1;
}
sanitized.truncate(boundary);
sanitized.push('…');
}
if sanitized.is_empty() {
".".to_string()
} else {
sanitized
}
}
fn validate_plugin_https_request(
record: &ResolvedPluginRecord,
request: &PluginHttpsRequest,
) -> Result<reqwest::Url, PluginHttpsError> {
let method = request.method.trim().to_ascii_uppercase();
if method != request.method || !PLUGIN_HTTPS_ALLOWED_METHODS.contains(&method.as_str()) {
return Err(PluginHttpsError::new(format!(
"HTTPS method `{}` is not allowed",
request.method
)));
}
if request.headers.len() > PLUGIN_HTTPS_MAX_REQUEST_HEADERS {
return Err(PluginHttpsError::new(format!(
"HTTPS request has too many headers (max {})",
PLUGIN_HTTPS_MAX_REQUEST_HEADERS
)));
}
for header in &request.headers {
validate_https_header(header)?;
}
if let Some(body) = &request.body {
if body.len() > PLUGIN_HTTPS_MAX_REQUEST_BODY_BYTES {
return Err(PluginHttpsError::new(format!(
"HTTPS request body exceeds {} bytes",
PLUGIN_HTTPS_MAX_REQUEST_BODY_BYTES
)));
}
}
let url = reqwest::Url::parse(&request.url)
.map_err(|error| PluginHttpsError::new(format!("invalid HTTPS URL: {error}")))?;
if url.scheme() != "https" {
return Err(PluginHttpsError::new(format!(
"unsupported URL scheme {:?}; only https is allowed",
url.scheme()
)));
}
if url.host_str().is_none() {
return Err(PluginHttpsError::new("HTTPS URL must include a host"));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(PluginHttpsError::new(
"HTTPS URLs with embedded credentials are not allowed",
));
}
validate_static_https_target(&url)?;
authorize_https_allowlist(record, &method, &url)?;
Ok(url)
}
fn validate_https_header(header: &PluginHttpsHeader) -> Result<(), PluginHttpsError> {
if header.name.is_empty() || header.name.len() > PLUGIN_HTTPS_MAX_HEADER_NAME_BYTES {
return Err(PluginHttpsError::new(
"HTTPS request header name is invalid",
));
}
if header.value.len() > PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES {
return Err(PluginHttpsError::new(format!(
"HTTPS request header `{}` exceeds {} bytes",
header.name, PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES
)));
}
if is_sensitive_header(&header.name) {
return Err(PluginHttpsError::new(format!(
"HTTPS request header `{}` is credential-like and must be supplied by an explicit future secret-ref grant, not guest memory",
header.name
)));
}
reqwest::header::HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| {
PluginHttpsError::new(format!(
"invalid HTTPS request header name `{}`",
header.name
))
})?;
reqwest::header::HeaderValue::from_str(&header.value).map_err(|_| {
PluginHttpsError::new(format!(
"invalid HTTPS request header value for `{}`",
header.name
))
})?;
Ok(())
}
fn authorize_https_allowlist(
record: &ResolvedPluginRecord,
method: &str,
url: &reqwest::Url,
) -> Result<(), PluginHttpsError> {
let host = canonical_host(url)?;
let path = url.path();
let allowed = record.grants.https.iter().any(|grant| {
canonical_grant_host(&grant.host).as_deref() == Some(host.as_str())
&& grant
.methods
.iter()
.any(|allowed_method| allowed_method.eq_ignore_ascii_case(method))
&& (grant.path_prefixes.is_empty()
|| grant
.path_prefixes
.iter()
.any(|prefix| !prefix.is_empty() && path.starts_with(prefix)))
});
if allowed {
return Ok(());
}
Err(PluginHttpsError::new(format!(
"HTTPS request {} {} is not covered by host/method/path grants",
method,
safe_url(url)
)))
}
fn canonical_grant_host(host: &str) -> Option<String> {
let value = normalize_host_literal(host.trim());
if value.is_empty() { None } else { Some(value) }
}
fn normalize_host_literal(host: &str) -> String {
host.trim_end_matches('.')
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase()
}
fn has_usable_https_grant(record: &ResolvedPluginRecord) -> bool {
record.grants.https.iter().any(|grant| {
canonical_grant_host(&grant.host).is_some()
&& grant.methods.iter().any(|method| {
let method = method.trim().to_ascii_uppercase();
PLUGIN_HTTPS_ALLOWED_METHODS.contains(&method.as_str())
})
})
}
fn has_usable_fs_grant(record: &ResolvedPluginRecord) -> bool {
record.grants.fs.iter().any(|grant| {
!grant.root.trim().is_empty()
&& Path::new(&grant.root).is_absolute()
&& !grant.operations.is_empty()
})
}
fn canonical_host(url: &reqwest::Url) -> Result<String, PluginHttpsError> {
url.host_str()
.map(normalize_host_literal)
.filter(|host| !host.is_empty())
.ok_or_else(|| PluginHttpsError::new("HTTPS URL must include a host"))
}
fn validate_static_https_target(url: &reqwest::Url) -> Result<(), PluginHttpsError> {
let host = canonical_host(url)?;
if is_forbidden_host_name(&host) {
return Err(PluginHttpsError::new(format!(
"HTTPS blocked local/private host {:?}",
host
)));
}
if let Ok(ip) = host.parse::<IpAddr>() {
validate_public_ip(ip, &host)?;
}
if url.cannot_be_a_base() {
return Err(PluginHttpsError::new(
"HTTPS URL target is not hierarchical",
));
}
Ok(())
}
fn resolve_https_target_for_client(
url: &reqwest::Url,
resolver: &dyn PluginHttpsResolver,
) -> Result<Option<PinnedHttpsResolution>, PluginHttpsError> {
let host = canonical_host(url)?;
if host.parse::<IpAddr>().is_ok() {
return Ok(None);
}
let port = url
.port_or_known_default()
.ok_or_else(|| PluginHttpsError::new("HTTPS URL uses a scheme without a default port"))?;
let addrs = resolver.resolve(&host, port)?;
if addrs.is_empty() {
return Err(PluginHttpsError::new(format!(
"DNS lookup for {:?} returned no addresses",
host
)));
}
for addr in &addrs {
validate_public_ip(addr.ip(), &host)?;
}
let mut domains = Vec::new();
if let Some(raw_host) = url.host_str() {
let raw_host = raw_host
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
if !raw_host.is_empty() {
domains.push(raw_host);
}
}
if !domains.contains(&host) {
domains.push(host);
}
Ok(Some(PinnedHttpsResolution { domains, addrs }))
}
fn validate_public_ip(ip: IpAddr, host: &str) -> Result<(), PluginHttpsError> {
let forbidden = match ip {
IpAddr::V4(ip) => is_forbidden_ipv4(ip),
IpAddr::V6(ip) => is_forbidden_ipv6(ip),
};
if forbidden {
return Err(PluginHttpsError::new(format!(
"HTTPS blocked local/private address {ip} for host {:?}",
host
)));
}
Ok(())
}
fn is_forbidden_host_name(host: &str) -> bool {
let lower = host.trim_end_matches('.').to_ascii_lowercase();
lower == "localhost" || lower.ends_with(".localhost")
}
fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool {
ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_broadcast()
|| ip.is_documentation()
|| ip.is_unspecified()
|| ip.octets()[0] == 0
|| ip.octets()[0] >= 224
|| ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1])
|| ip.octets()[0] == 169 && ip.octets()[1] == 254
|| ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0
|| ip.octets()[0] == 198 && (18..=19).contains(&ip.octets()[1])
}
fn is_forbidden_ipv6(ip: Ipv6Addr) -> bool {
if let Some(mapped) = ipv6_embedded_ipv4(ip) {
return is_forbidden_ipv4(mapped);
}
ip.is_loopback()
|| ip.is_unspecified()
|| (ip.segments()[0] & 0xfe00) == 0xfc00
|| (ip.segments()[0] & 0xffc0) == 0xfe80
|| (ip.segments()[0] & 0xff00) == 0xff00
}
fn ipv6_embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
if let Some(mapped) = ip.to_ipv4_mapped() {
return Some(mapped);
}
let segments = ip.segments();
if segments[..6] == [0, 0, 0, 0, 0, 0] {
return Some(Ipv4Addr::new(
(segments[6] >> 8) as u8,
segments[6] as u8,
(segments[7] >> 8) as u8,
segments[7] as u8,
));
}
None
}
fn collect_https_response_headers(headers: &reqwest::header::HeaderMap) -> Vec<PluginHttpsHeader> {
headers
.iter()
.filter(|(name, _)| !is_sensitive_header(name.as_str()))
.take(PLUGIN_HTTPS_MAX_RESPONSE_HEADERS)
.filter_map(|(name, value)| {
value.to_str().ok().map(|value| PluginHttpsHeader {
name: name.as_str().to_string(),
value: bounded_header_value(value),
})
})
.collect()
}
fn bounded_header_value(value: &str) -> String {
let mut redacted = redact_secret_like(value);
if redacted.len() > PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES {
truncate_string_to_boundary(&mut redacted, PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES);
redacted.push('…');
}
redacted
}
fn is_sensitive_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"authorization"
| "proxy-authorization"
| "cookie"
| "set-cookie"
| "x-api-key"
| "x-auth-token"
| "api-key"
| "apikey"
)
}
fn safe_url(url: &reqwest::Url) -> String {
let host = url.host_str().unwrap_or("<missing-host>");
let mut path = url.path().to_string();
if path.len() > 120 {
truncate_string_to_boundary(&mut path, 120);
path.push('…');
}
match url.port() {
Some(port) => format!("https://{host}:{port}{path}"),
None => format!("https://{host}{path}"),
}
}
fn redact_secret_like(message: &str) -> String {
let mut value = message.to_string();
for needle in [
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-api-key",
"x-auth-token",
"api-key",
"token",
"secret",
"password",
] {
value = redact_after_secret_word(&value, needle);
}
value
}
fn redact_after_secret_word(input: &str, needle: &str) -> String {
let lower = input.to_ascii_lowercase();
let mut out = String::new();
let mut cursor = 0usize;
while let Some(relative) = lower[cursor..].find(needle) {
let start = cursor + relative;
let mut end = start + needle.len();
out.push_str(&input[cursor..end]);
let bytes = input.as_bytes();
while end < input.len() && matches!(bytes[end], b' ' | b'=' | b':' | b'\t') {
out.push(bytes[end] as char);
end += 1;
}
let secret_start = end;
while end < input.len() && !matches!(bytes[end], b' ' | b',' | b';' | b'\n' | b'\r') {
end += 1;
}
if end > secret_start {
out.push_str(PLUGIN_HTTPS_REDACTION);
}
cursor = end;
}
out.push_str(&input[cursor..]);
out
}
#[derive(Debug)]
struct PluginPermissionError(String);
impl PluginPermissionError {
fn bounded_message(&self) -> String {
bounded_message(&self.0)
}
}
fn authorize_plugin_tool(
record: &ResolvedPluginRecord,
tool: &PluginToolManifest,
) -> Result<(), PluginPermissionError> {
validate_grant_binding(record)?;
require_permission(
&record.manifest.permissions,
&PluginPermission::surface(PluginSurface::Tool),
"requested surfaces.tool permission is missing",
)?;
require_permission(
&record.grants.permissions,
&PluginPermission::surface(PluginSurface::Tool),
"granted surfaces.tool permission is missing",
)?;
if !permission_allows_tool(&record.manifest.permissions, &tool.name) {
return Err(PluginPermissionError(format!(
"requested tool permission for `{}` is missing",
tool.name
)));
}
if !permission_allows_tool(&record.grants.permissions, &tool.name) {
return Err(PluginPermissionError(format!(
"granted tool permission for `{}` is missing",
tool.name
)));
}
if tool.external_write {
require_permission(
&record.manifest.permissions,
&PluginPermission::ExternalWrite,
"requested external_write permission is missing",
)?;
require_permission(
&record.grants.permissions,
&PluginPermission::ExternalWrite,
"granted external_write permission is missing",
)?;
}
Ok(())
}
fn authorize_plugin_host_api(
record: &ResolvedPluginRecord,
api: PluginHostApi,
) -> Result<(), PluginPermissionError> {
validate_grant_binding(record)?;
let permission = PluginPermission::host_api(api);
require_permission(
&record.manifest.permissions,
&permission,
&format!("requested host_api.{api} permission is missing"),
)?;
require_permission(
&record.grants.permissions,
&permission,
&format!("granted host_api.{api} permission is missing"),
)?;
match api {
PluginHostApi::Https => {
if !has_usable_https_grant(record) {
return Err(PluginPermissionError(
"granted host_api.https allowlist is missing".to_string(),
));
}
Ok(())
}
PluginHostApi::Fs => {
if !has_usable_fs_grant(record) {
return Err(PluginPermissionError(
"granted host_api.fs scope allowlist is missing".to_string(),
));
}
Ok(())
}
}
}
fn validate_grant_binding(record: &ResolvedPluginRecord) -> Result<(), PluginPermissionError> {
if let Some(message) =
record
.grants
.binding_error(&record.identity, &record.digest, &record.manifest.version)
{
return Err(PluginPermissionError(message.to_string()));
}
Ok(())
}
fn require_permission(
permissions: &[PluginPermission],
expected: &PluginPermission,
missing_message: &str,
) -> Result<(), PluginPermissionError> {
if permissions.iter().any(|permission| permission == expected) {
return Ok(());
}
Err(PluginPermissionError(missing_message.to_string()))
}
fn permission_allows_tool(permissions: &[PluginPermission], tool_name: &str) -> bool {
permissions.iter().any(|permission| match permission {
PluginPermission::Tool { name } => name == tool_name,
PluginPermission::ToolNamespace { namespace } => {
!namespace.is_empty() && tool_name.starts_with(namespace)
}
_ => false,
})
}
const PLUGIN_WASM_HOST_MODULE: &str = "yoi:tool";
const PLUGIN_WASM_ENTRYPOINT: &str = "yoi_tool_call";
const PLUGIN_WASM_MAX_INPUT_BYTES: usize = 64 * 1024;
const PLUGIN_WASM_MAX_OUTPUT_BYTES: usize = 64 * 1024;
const PLUGIN_WASM_MAX_SUMMARY_BYTES: usize = 1024;
const PLUGIN_WASM_FUEL: u64 = 5_000_000;
const PLUGIN_WASM_TIMEOUT: Duration = Duration::from_secs(1);
const PLUGIN_WASM_MEMORY_BYTES: usize = 2 * 1024 * 1024;
const PLUGIN_WASM_TABLE_ELEMENTS: usize = 256;
const PLUGIN_WASM_HTTPS_MODULE: &str = "yoi:https";
const PLUGIN_WASM_FS_MODULE: &str = "yoi:fs";
const PLUGIN_HTTPS_MAX_REQUEST_BYTES: usize = 48 * 1024;
const PLUGIN_HTTPS_MAX_REQUEST_BODY_BYTES: usize = 32 * 1024;
const PLUGIN_HTTPS_MAX_REQUEST_HEADERS: usize = 16;
const PLUGIN_HTTPS_MAX_RESPONSE_HEADERS: usize = 16;
const PLUGIN_HTTPS_MAX_HEADER_NAME_BYTES: usize = 64;
const PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES: usize = 1024;
const PLUGIN_HTTPS_MAX_RESPONSE_BYTES: usize = 64 * 1024;
const PLUGIN_HTTPS_TIMEOUT: Duration = Duration::from_secs(5);
const PLUGIN_HTTPS_ALLOWED_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"];
const PLUGIN_HTTPS_REDACTION: &str = "<redacted>";
const PLUGIN_FS_MAX_REQUEST_BYTES: usize = 64 * 1024;
const PLUGIN_FS_MAX_PATH_BYTES: usize = 4096;
const PLUGIN_FS_MAX_READ_BYTES: usize = 64 * 1024;
const PLUGIN_FS_MAX_WRITE_BYTES: usize = 64 * 1024;
const PLUGIN_FS_MAX_LIST_ENTRIES: usize = 256;
fn wasm_component_store_limits() -> wasmtime::StoreLimits {
wasmtime::StoreLimitsBuilder::new()
.memory_size(PLUGIN_WASM_MEMORY_BYTES)
.table_elements(PLUGIN_WASM_TABLE_ELEMENTS)
.instances(1)
.tables(1)
.memories(1)
.trap_on_grow_failure(true)
.build()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PluginFsRuntimeOperation {
Read,
List,
Write,
}
impl PluginFsRuntimeOperation {
fn grant_operation(self) -> PluginFsOperation {
match self {
Self::Read => PluginFsOperation::Read,
Self::List => PluginFsOperation::List,
Self::Write => PluginFsOperation::Write,
}
}
fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::List => "list",
Self::Write => "write",
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginFsPathRequest {
path: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginFsWriteRequest {
path: String,
content: String,
}
#[derive(Clone, Debug, Serialize)]
struct PluginFsReadResponse {
path: String,
content: String,
truncated: bool,
}
#[derive(Clone, Debug, Serialize)]
struct PluginFsListResponse {
path: String,
entries: Vec<PluginFsDirEntry>,
truncated: bool,
}
#[derive(Clone, Debug, Serialize)]
struct PluginFsDirEntry {
name: String,
kind: String,
}
#[derive(Clone, Debug, Serialize)]
struct PluginFsWriteResponse {
path: String,
bytes_written: usize,
}
#[derive(Debug)]
struct PluginFsError {
message: String,
}
impl PluginFsError {
fn new(message: impl Into<String>) -> Self {
let message = message.into();
Self {
message: bounded_message(redact_secret_like(&message)),
}
}
}
impl std::fmt::Display for PluginFsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for PluginFsError {}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginHttpsRequest {
method: String,
url: String,
#[serde(default)]
headers: Vec<PluginHttpsHeader>,
#[serde(default)]
body: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginHttpsHeader {
name: String,
value: String,
}
#[derive(Clone, Debug, Serialize)]
struct PluginHttpsResponse {
status: u16,
headers: Vec<PluginHttpsHeader>,
body: String,
truncated: bool,
}
#[derive(Clone, Copy, Debug)]
struct PluginHttpsLimits {
timeout: Duration,
max_response_bytes: usize,
}
impl Default for PluginHttpsLimits {
fn default() -> Self {
Self {
timeout: PLUGIN_HTTPS_TIMEOUT,
max_response_bytes: PLUGIN_HTTPS_MAX_RESPONSE_BYTES,
}
}
}
trait PluginHttpsClient: Send + Sync {
fn execute(
&self,
request: &PluginHttpsRequest,
url: &reqwest::Url,
limits: PluginHttpsLimits,
) -> Result<PluginHttpsResponse, PluginHttpsError>;
}
struct ReqwestPluginHttpsClient;
struct SystemPluginHttpsResolver;
#[derive(Clone, Debug)]
struct PinnedHttpsResolution {
domains: Vec<String>,
addrs: Vec<SocketAddr>,
}
trait PluginHttpsResolver {
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, PluginHttpsError>;
}
impl PluginHttpsResolver for SystemPluginHttpsResolver {
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, PluginHttpsError> {
let mut addrs = Vec::new();
for addr in (host, port).to_socket_addrs().map_err(|error| {
PluginHttpsError::new(format!("DNS lookup failed for {:?}: {error}", host))
})? {
addrs.push(addr);
}
Ok(addrs)
}
}
#[derive(Debug)]
struct PluginHttpsError(String);
impl PluginHttpsError {
fn new(message: impl Into<String>) -> Self {
Self(redact_secret_like(&bounded_message(message.into())))
}
}
fn plugin_wasm_tool_definition(
record: ResolvedPluginRecord,
name: String,
description: String,
input_schema: Value,
origin: ToolOrigin,
) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(name.clone())
.description(description.clone())
.input_schema(input_schema.clone())
.origin(origin.clone()),
Arc::new(PluginWasmTool {
record: record.clone(),
name: name.clone(),
origin: origin.clone(),
}) as Arc<dyn Tool>,
)
})
}
struct PluginWasmTool {
record: ResolvedPluginRecord,
name: String,
origin: ToolOrigin,
}
#[async_trait]
impl Tool for PluginWasmTool {
async fn execute(
&self,
input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
if input_json.len() > PLUGIN_WASM_MAX_INPUT_BYTES {
return Err(ToolError::InvalidArgument(format!(
"plugin tool `{}` input exceeds {} bytes",
self.name, PLUGIN_WASM_MAX_INPUT_BYTES
)));
}
serde_json::from_str::<Value>(input_json).map_err(|error| {
ToolError::InvalidArgument(format!(
"plugin tool `{}` input is not valid JSON: {}",
self.name,
bounded_message(error.to_string())
))
})?;
let record = self.record.clone();
let name = self.name.clone();
let plugin_ref = self.origin.plugin_ref.clone();
let digest = self.origin.digest.clone();
let input = input_json.as_bytes().to_vec();
let execution = tokio::task::spawn_blocking(move || run_plugin_tool(record, name, input));
match tokio::time::timeout(PLUGIN_WASM_TIMEOUT, execution).await {
Ok(Ok(Ok(output))) => Ok(output),
Ok(Ok(Err(error))) => Err(ToolError::ExecutionFailed(format!(
"plugin tool `{}` from `{}` (digest {}) failed closed: {}",
self.name,
plugin_ref,
digest,
error.bounded_message()
))),
Ok(Err(error)) => Err(ToolError::ExecutionFailed(format!(
"plugin tool `{}` from `{}` (digest {}) cancelled/failed to join: {}",
self.name,
plugin_ref,
digest,
bounded_message(error.to_string())
))),
Err(_) => Err(ToolError::ExecutionFailed(format!(
"plugin tool `{}` from `{}` (digest {}) timed out after {:?}",
self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT
))),
}
}
}
#[derive(Debug)]
enum PluginWasmError {
Package(String),
Module(String),
Execution(String),
Output(String),
}
impl PluginWasmError {
fn bounded_message(&self) -> String {
match self {
Self::Package(message) => {
bounded_message(format!("package/module load error: {message}"))
}
Self::Module(message) => bounded_message(format!("WASM module error: {message}")),
Self::Execution(message) => bounded_message(format!("WASM execution error: {message}")),
Self::Output(message) => bounded_message(format!("WASM output error: {message}")),
}
}
}
struct PluginWasmHostState {
record: ResolvedPluginRecord,
https_client: Arc<dyn PluginHttpsClient>,
tool_name: Vec<u8>,
input: Vec<u8>,
output: Vec<u8>,
output_error: Option<String>,
https_response: Vec<u8>,
fs_response: Vec<u8>,
store_limits: wasmi::StoreLimits,
}
fn run_plugin_tool(
record: ResolvedPluginRecord,
tool_name: String,
input: Vec<u8>,
) -> Result<ToolOutput, PluginWasmError> {
match record
.manifest
.runtime
.as_ref()
.map(|runtime| runtime.kind.as_str())
{
Some(PLUGIN_RUNTIME_WASM_KIND) => run_plugin_wasm_tool(record, tool_name, input),
Some(PLUGIN_RUNTIME_COMPONENT_KIND) => run_plugin_component_tool(record, tool_name, input),
Some(other) => Err(PluginWasmError::Module(format!(
"unsupported plugin runtime kind `{other}`"
))),
None => Err(PluginWasmError::Package(
"plugin runtime is not declared".to_string(),
)),
}
}
fn run_plugin_wasm_tool(
record: ResolvedPluginRecord,
tool_name: String,
input: Vec<u8>,
) -> Result<ToolOutput, PluginWasmError> {
run_plugin_wasm_tool_with_https_client(
record,
tool_name,
input,
Arc::new(ReqwestPluginHttpsClient),
)
}
fn run_plugin_wasm_tool_with_https_client(
record: ResolvedPluginRecord,
tool_name: String,
input: Vec<u8>,
https_client: Arc<dyn PluginHttpsClient>,
) -> Result<ToolOutput, PluginWasmError> {
let tool = record
.manifest
.tools
.iter()
.find(|tool| tool.name == tool_name)
.ok_or_else(|| {
PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string())
})?;
authorize_plugin_tool(&record, tool).map_err(|error| {
PluginWasmError::Module(format!(
"plugin permission denied: {}",
error.bounded_message()
))
})?;
let limits = PluginDiscoveryLimits::default();
let module_bytes = read_resolved_plugin_runtime_module(&record, &limits)
.map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?;
if module_bytes.len() > limits.max_file_size_bytes as usize {
return Err(PluginWasmError::Package(format!(
"WASM runtime module exceeds {} bytes",
limits.max_file_size_bytes
)));
}
let mut config = wasmi::Config::default();
config.consume_fuel(true);
config.set_max_recursion_depth(64);
config.set_max_stack_height(8 * 1024 * 1024);
let engine = wasmi::Engine::new(&config);
let module = wasmi::Module::new(&engine, &module_bytes[..])
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
validate_wasm_imports(&record, &module)?;
let store_limits = wasmi::StoreLimitsBuilder::new()
.memory_size(PLUGIN_WASM_MEMORY_BYTES)
.table_elements(PLUGIN_WASM_TABLE_ELEMENTS)
.instances(1)
.tables(1)
.memories(1)
.trap_on_grow_failure(true)
.build();
let mut store = wasmi::Store::new(
&engine,
PluginWasmHostState {
record: record.clone(),
https_client,
tool_name: tool_name.into_bytes(),
input,
output: Vec::new(),
output_error: None,
https_response: Vec::new(),
fs_response: Vec::new(),
store_limits,
},
);
store.limiter(|state| &mut state.store_limits);
store
.set_fuel(PLUGIN_WASM_FUEL)
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
let mut linker = wasmi::Linker::<PluginWasmHostState>::new(&engine);
define_plugin_wasm_host_imports(&mut linker)?;
let instance = linker
.instantiate_and_start(&mut store, &module)
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
let entry = instance
.get_typed_func::<(), ()>(&store, PLUGIN_WASM_ENTRYPOINT)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
entry
.call(&mut store, ())
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
if let Some(error) = store.data().output_error.clone() {
return Err(PluginWasmError::Output(error));
}
decode_plugin_wasm_output(&store.data().output)
}
#[derive(Clone)]
struct PluginComponentHostState {
record: ResolvedPluginRecord,
https_client: Arc<dyn PluginHttpsClient>,
store_limits: wasmtime::StoreLimits,
}
fn run_plugin_component_tool(
record: ResolvedPluginRecord,
tool_name: String,
input: Vec<u8>,
) -> Result<ToolOutput, PluginWasmError> {
run_plugin_component_tool_with_https_client(
record,
tool_name,
input,
Arc::new(ReqwestPluginHttpsClient),
)
}
fn run_plugin_component_tool_with_https_client(
record: ResolvedPluginRecord,
tool_name: String,
input: Vec<u8>,
https_client: Arc<dyn PluginHttpsClient>,
) -> Result<ToolOutput, PluginWasmError> {
let tool = record
.manifest
.tools
.iter()
.find(|tool| tool.name == tool_name)
.ok_or_else(|| {
PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string())
})?;
authorize_plugin_tool(&record, tool).map_err(|error| {
PluginWasmError::Module(format!(
"plugin permission denied: {}",
error.bounded_message()
))
})?;
let limits = PluginDiscoveryLimits::default();
let component_bytes = read_resolved_plugin_runtime_component(&record, &limits)
.map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?;
if component_bytes.len() > limits.max_file_size_bytes as usize {
return Err(PluginWasmError::Package(format!(
"WASM component runtime artifact exceeds {} bytes",
limits.max_file_size_bytes
)));
}
let mut config = wasmtime::Config::new();
config.wasm_component_model(true);
config.consume_fuel(true);
config.max_wasm_stack(8 * 1024 * 1024);
let engine = wasmtime::Engine::new(&config)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
let component =
wasmtime::component::Component::new(&engine, &component_bytes).map_err(|error| {
PluginWasmError::Module(format!("component is incompatible: {error:?}"))
})?;
validate_component_imports(&record, &engine, &component)?;
let mut linker = wasmtime::component::Linker::<PluginComponentHostState>::new(&engine);
define_plugin_component_host_imports(&mut linker)?;
let mut store = wasmtime::Store::new(
&engine,
PluginComponentHostState {
record: record.clone(),
https_client,
store_limits: wasm_component_store_limits(),
},
);
store.limiter(|state| &mut state.store_limits);
store
.set_fuel(PLUGIN_WASM_FUEL)
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
let instance = linker
.instantiate(&mut store, &component)
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
let call = instance
.get_typed_func::<(&str, &str), (String,)>(&mut store, "call")
.map_err(|error| {
PluginWasmError::Module(format!(
"component does not export expected `{}` call function: {error}",
PLUGIN_COMPONENT_TOOL_WORLD
))
})?;
let input_json = std::str::from_utf8(&input).map_err(|error| {
PluginWasmError::Output(format!("plugin component input is not UTF-8: {error}"))
})?;
// Wasmtime lifts the returned WIT `string` into a host `String` before the
// ordinary ToolOutput JSON cap can be applied. Keep the component store on
// the same memory/table/instance limits as the raw WASM runtime so an
// untrusted component can only force host string allocation from bounded
// component memory; oversized memories/tables/instances fail closed during
// instantiation/growth before this lift succeeds.
let (output,) = call
.call(&mut store, (&tool_name, input_json))
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
decode_plugin_wasm_output(output.as_bytes())
}
fn validate_component_imports(
record: &ResolvedPluginRecord,
engine: &wasmtime::Engine,
component: &wasmtime::component::Component,
) -> Result<(), PluginWasmError> {
for (name, _) in component.component_type().imports(engine) {
match name {
"yoi:host/https@1.0.0" => {
authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| {
PluginWasmError::Module(format!(
"plugin host API dispatch denied: {}",
error.bounded_message()
))
})?;
}
"yoi:host/fs@1.0.0" => {
authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| {
PluginWasmError::Module(format!(
"plugin host API dispatch denied: {}",
error.bounded_message()
))
})?;
}
other => {
return Err(PluginWasmError::Module(format!(
"unsupported component import `{other}`; no WASI filesystem, ambient network, environment, or other imports are available"
)));
}
}
}
Ok(())
}
fn define_plugin_component_host_imports(
linker: &mut wasmtime::component::Linker<PluginComponentHostState>,
) -> Result<(), PluginWasmError> {
linker
.root()
.instance("yoi:host/https@1.0.0")
.map_err(|error| PluginWasmError::Module(error.to_string()))?
.func_wrap(
"request",
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
(request,): (String,)|
-> wasmtime::Result<(String,)> {
authorize_plugin_host_api(&store.data().record, PluginHostApi::Https)
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
let response = execute_plugin_https_request(
&store.data().record,
store.data().https_client.as_ref(),
request.as_bytes(),
)
.map_err(|error| wasmtime::Error::msg(error.0))?;
Ok((String::from_utf8_lossy(&response).into_owned(),))
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
let mut root = linker.root();
let mut fs = root
.instance("yoi:host/fs@1.0.0")
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
fs.func_wrap(
"read",
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
(request,): (String,)|
-> wasmtime::Result<(String,)> {
authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs)
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
execute_plugin_fs_request(
&store.data().record,
PluginFsRuntimeOperation::Read,
request.as_bytes(),
)
.map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),))
.map_err(|error| wasmtime::Error::msg(error.message))
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
fs.func_wrap(
"list",
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
(request,): (String,)|
-> wasmtime::Result<(String,)> {
authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs)
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
execute_plugin_fs_request(
&store.data().record,
PluginFsRuntimeOperation::List,
request.as_bytes(),
)
.map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),))
.map_err(|error| wasmtime::Error::msg(error.message))
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
fs.func_wrap(
"write",
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
(request,): (String,)|
-> wasmtime::Result<(String,)> {
authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs)
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
execute_plugin_fs_request(
&store.data().record,
PluginFsRuntimeOperation::Write,
request.as_bytes(),
)
.map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),))
.map_err(|error| wasmtime::Error::msg(error.message))
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
Ok(())
}
fn validate_wasm_imports(
record: &ResolvedPluginRecord,
module: &wasmi::Module,
) -> Result<(), PluginWasmError> {
for import in module.imports() {
match import.module() {
PLUGIN_WASM_HOST_MODULE => match import.name() {
"tool_name_len" | "tool_name_read" | "input_len" | "input_read"
| "output_write" => {}
other => {
return Err(PluginWasmError::Module(format!(
"unsupported host import `{}`; no filesystem, ambient network, environment, or WASI imports are available",
other
)));
}
},
PLUGIN_WASM_HTTPS_MODULE => {
authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| {
PluginWasmError::Module(format!(
"plugin host API dispatch denied: {}",
error.bounded_message()
))
})?;
match import.name() {
"request" | "response_len" | "response_read" => {}
other => {
return Err(PluginWasmError::Module(format!(
"unsupported https host import `{other}`"
)));
}
}
}
PLUGIN_WASM_FS_MODULE => {
authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| {
PluginWasmError::Module(format!(
"plugin host API dispatch denied: {}",
error.bounded_message()
))
})?;
match import.name() {
"read" | "list" | "write" | "response_len" | "response_read" => {}
other => {
return Err(PluginWasmError::Module(format!(
"unsupported fs host import `{other}`"
)));
}
}
}
other => {
return Err(PluginWasmError::Module(format!(
"unsupported import module `{}`; only `{}`, `{}`, and `{}` are available",
other, PLUGIN_WASM_HOST_MODULE, PLUGIN_WASM_HTTPS_MODULE, PLUGIN_WASM_FS_MODULE
)));
}
}
}
Ok(())
}
fn define_plugin_wasm_host_imports(
linker: &mut wasmi::Linker<PluginWasmHostState>,
) -> Result<(), PluginWasmError> {
linker
.func_wrap(
PLUGIN_WASM_HOST_MODULE,
"tool_name_len",
|caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 {
caller.data().tool_name.len() as i32
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HOST_MODULE,
"input_len",
|caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 {
caller.data().input.len() as i32
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HOST_MODULE,
"tool_name_read",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::ToolName)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HOST_MODULE,
"input_read",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::Input)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HOST_MODULE,
"output_write",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
read_guest_output(&mut caller, ptr, len)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HTTPS_MODULE,
"request",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
read_guest_https_request(&mut caller, ptr, len)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HTTPS_MODULE,
"response_len",
|caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 {
caller.data().https_response.len() as i32
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HTTPS_MODULE,
"response_read",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::HttpsResponse)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_FS_MODULE,
"read",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
read_guest_fs_request(&mut caller, ptr, len, PluginFsRuntimeOperation::Read)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_FS_MODULE,
"list",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
read_guest_fs_request(&mut caller, ptr, len, PluginFsRuntimeOperation::List)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_FS_MODULE,
"write",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
read_guest_fs_request(&mut caller, ptr, len, PluginFsRuntimeOperation::Write)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_FS_MODULE,
"response_len",
|caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 {
caller.data().fs_response.len() as i32
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_FS_MODULE,
"response_read",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::FsResponse)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
Ok(())
}
#[derive(Clone, Copy, Debug)]
enum HostBuffer {
ToolName,
Input,
HttpsResponse,
FsResponse,
}
fn write_host_bytes_to_guest(
caller: &mut wasmi::Caller<'_, PluginWasmHostState>,
ptr: i32,
len: i32,
buffer: HostBuffer,
) -> i32 {
if ptr < 0 || len < 0 {
return -1;
}
let bytes = match buffer {
HostBuffer::ToolName => caller.data().tool_name.clone(),
HostBuffer::Input => caller.data().input.clone(),
HostBuffer::HttpsResponse => caller.data().https_response.clone(),
HostBuffer::FsResponse => caller.data().fs_response.clone(),
};
if len as usize != bytes.len() {
return -1;
}
let Some(memory) = caller
.get_export("memory")
.and_then(|export| export.into_memory())
else {
return -1;
};
match memory.write(caller, ptr as usize, &bytes) {
Ok(()) => bytes.len() as i32,
Err(_) => -1,
}
}
fn read_guest_https_request(
caller: &mut wasmi::Caller<'_, PluginWasmHostState>,
ptr: i32,
len: i32,
) -> i32 {
let bytes = match read_guest_bytes(caller, ptr, len, PLUGIN_HTTPS_MAX_REQUEST_BYTES) {
Ok(bytes) => bytes,
Err(error) => {
caller.data_mut().output_error = Some(error);
return -1;
}
};
let record = caller.data().record.clone();
let https_client = caller.data().https_client.clone();
match execute_plugin_https_request(&record, https_client.as_ref(), &bytes) {
Ok(response) => {
caller.data_mut().https_response = response;
caller.data().https_response.len() as i32
}
Err(error) => {
caller.data_mut().output_error = Some(error.0);
-1
}
}
}
fn read_guest_fs_request(
caller: &mut wasmi::Caller<'_, PluginWasmHostState>,
ptr: i32,
len: i32,
operation: PluginFsRuntimeOperation,
) -> i32 {
let bytes = match read_guest_bytes(caller, ptr, len, PLUGIN_FS_MAX_REQUEST_BYTES) {
Ok(bytes) => bytes,
Err(error) => {
caller.data_mut().output_error = Some(error);
return -1;
}
};
let record = caller.data().record.clone();
match execute_plugin_fs_request(&record, operation, &bytes) {
Ok(response) => {
caller.data_mut().fs_response = response;
caller.data().fs_response.len() as i32
}
Err(error) => {
caller.data_mut().output_error = Some(error.message);
-1
}
}
}
fn read_guest_bytes(
caller: &mut wasmi::Caller<'_, PluginWasmHostState>,
ptr: i32,
len: i32,
max_len: usize,
) -> Result<Vec<u8>, String> {
if ptr < 0 || len < 0 {
return Err("guest input pointer/length is invalid".into());
}
let len = len as usize;
if len > max_len {
return Err(format!("guest input exceeds {max_len} bytes"));
}
let Some(memory) = caller
.get_export("memory")
.and_then(|export| export.into_memory())
else {
return Err("guest did not export linear memory".into());
};
let mut bytes = vec![0; len];
memory
.read(&*caller, ptr as usize, &mut bytes)
.map_err(|_| "guest input memory range is invalid".to_string())?;
Ok(bytes)
}
fn read_guest_output(
caller: &mut wasmi::Caller<'_, PluginWasmHostState>,
ptr: i32,
len: i32,
) -> i32 {
if ptr < 0 || len < 0 {
caller.data_mut().output_error = Some("guest output pointer/length is invalid".into());
return -1;
}
let len = len as usize;
if len > PLUGIN_WASM_MAX_OUTPUT_BYTES {
caller.data_mut().output_error = Some(format!(
"guest output exceeds {} bytes",
PLUGIN_WASM_MAX_OUTPUT_BYTES
));
return -1;
}
let Some(memory) = caller
.get_export("memory")
.and_then(|export| export.into_memory())
else {
caller.data_mut().output_error = Some("guest did not export linear memory".into());
return -1;
};
let mut output = vec![0; len];
if memory.read(&*caller, ptr as usize, &mut output).is_err() {
caller.data_mut().output_error = Some("guest output memory range is invalid".into());
return -1;
}
caller.data_mut().output = output;
len as i32
}
fn decode_plugin_wasm_output(bytes: &[u8]) -> Result<ToolOutput, PluginWasmError> {
if bytes.is_empty() {
return Err(PluginWasmError::Output(
"guest did not call output_write".into(),
));
}
if bytes.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES {
return Err(PluginWasmError::Output(format!(
"guest output exceeds {} bytes",
PLUGIN_WASM_MAX_OUTPUT_BYTES
)));
}
let text = std::str::from_utf8(bytes)
.map_err(|error| PluginWasmError::Output(format!("guest output is not UTF-8: {error}")))?;
let value: Value = serde_json::from_str(text).map_err(|error| {
PluginWasmError::Output(format!("guest output is not valid JSON: {error}"))
})?;
let Value::Object(map) = value else {
return Err(PluginWasmError::Output(
"guest output JSON must be an object".into(),
));
};
for key in map.keys() {
if key != "summary" && key != "content" {
return Err(PluginWasmError::Output(format!(
"guest output contains unsupported key `{key}`"
)));
}
}
let summary = match map.get("summary") {
Some(Value::String(summary)) if !summary.is_empty() => summary.clone(),
Some(Value::String(_)) => {
return Err(PluginWasmError::Output(
"guest output summary must not be empty".into(),
));
}
Some(_) => {
return Err(PluginWasmError::Output(
"guest output summary must be a string".into(),
));
}
None => {
return Err(PluginWasmError::Output(
"guest output must include a summary string".into(),
));
}
};
if summary.len() > PLUGIN_WASM_MAX_SUMMARY_BYTES {
return Err(PluginWasmError::Output(format!(
"guest output summary exceeds {} bytes",
PLUGIN_WASM_MAX_SUMMARY_BYTES
)));
}
let content = match map.get("content") {
Some(Value::String(content)) => {
if content.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES {
return Err(PluginWasmError::Output(format!(
"guest output content exceeds {} bytes",
PLUGIN_WASM_MAX_OUTPUT_BYTES
)));
}
Some(content.clone())
}
Some(Value::Null) | None => None,
Some(_) => {
return Err(PluginWasmError::Output(
"guest output content must be a string or null".into(),
));
}
};
Ok(ToolOutput { summary, content })
}
fn bounded_message(message: impl Into<String>) -> String {
let message = message.into();
let mut sanitized = String::with_capacity(message.len().min(512));
for ch in message.chars() {
if ch.is_control() && ch != '\n' && ch != '\t' {
sanitized.push(' ');
} else {
sanitized.push(ch);
}
if sanitized.len() >= 512 {
sanitized.truncate(512);
sanitized.push('…');
break;
}
}
sanitized
}
fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> {
let mut seen = HashSet::new();
for tool in &record.manifest.tools {
if !seen.insert(tool.name.as_str()) {
return Err(FeatureInstallError::DuplicateToolName {
tool: tool.name.clone(),
first_feature: format!("{} (same plugin package)", record.identity),
duplicate_feature: record.identity.to_string(),
});
}
}
Ok(())
}
fn validate_tool_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("name must not be empty");
}
if name.len() > 128 {
return Err("name is longer than 128 bytes");
}
if name.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err("name must not contain whitespace or control characters");
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SupportedSchemaType {
Object,
Array,
String,
Number,
Integer,
Boolean,
Null,
}
impl SupportedSchemaType {
fn parse(value: &str) -> Option<Self> {
match value {
"object" => Some(Self::Object),
"array" => Some(Self::Array),
"string" => Some(Self::String),
"number" => Some(Self::Number),
"integer" => Some(Self::Integer),
"boolean" => Some(Self::Boolean),
"null" => Some(Self::Null),
_ => None,
}
}
}
fn validate_input_schema(schema: &Value) -> Result<(), String> {
let ty = validate_schema_node(schema, "$", true)?;
if ty != SupportedSchemaType::Object {
return Err("root schema type must be `object`".into());
}
Ok(())
}
fn validate_schema_node(
schema: &Value,
path: &str,
root: bool,
) -> Result<SupportedSchemaType, String> {
let Value::Object(map) = schema else {
return Err(format!("{path}: schema node must be a JSON object"));
};
for key in map.keys() {
if !is_supported_schema_keyword(key) {
return Err(format!("{path}: unsupported schema keyword `{key}`"));
}
}
let ty = match map.get("type") {
Some(Value::String(value)) => SupportedSchemaType::parse(value)
.ok_or_else(|| format!("{path}: unsupported schema type `{value}`"))?,
Some(_) => return Err(format!("{path}: type must be a string")),
None if root => return Err("root schema must declare type = `object`".into()),
None => return Err(format!("{path}: schema node must declare type")),
};
if let Some(title) = map.get("title") {
if !title.is_string() {
return Err(format!("{path}: title must be a string"));
}
}
if let Some(description) = map.get("description") {
if !description.is_string() {
return Err(format!("{path}: description must be a string"));
}
}
let properties = map.get("properties");
if let Some(properties) = properties {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: properties is only supported for object schemas"
));
}
let Some(properties) = properties.as_object() else {
return Err(format!("{path}: properties must be a JSON object"));
};
for (name, child_schema) in properties {
validate_schema_node(child_schema, &format!("{path}.properties.{name}"), false)?;
}
}
if let Some(required) = map.get("required") {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: required is only supported for object schemas"
));
}
let Some(required) = required.as_array() else {
return Err(format!("{path}: required must be an array"));
};
let mut seen = HashSet::new();
for entry in required {
let Some(name) = entry.as_str() else {
return Err(format!("{path}: required entries must be strings"));
};
if !seen.insert(name) {
return Err(format!("{path}: required entries must be unique"));
}
if let Some(properties) = properties.and_then(Value::as_object) {
if !properties.contains_key(name) {
return Err(format!(
"{path}: required entry `{name}` is not declared in properties"
));
}
}
}
}
if let Some(additional) = map.get("additionalProperties") {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: additionalProperties is only supported for object schemas"
));
}
match additional {
Value::Bool(_) => {}
Value::Object(_) => {
validate_schema_node(additional, &format!("{path}.additionalProperties"), false)?;
}
_ => {
return Err(format!(
"{path}: additionalProperties must be boolean or schema object"
));
}
}
}
if let Some(items) = map.get("items") {
if ty != SupportedSchemaType::Array {
return Err(format!("{path}: items is only supported for array schemas"));
}
validate_schema_node(items, &format!("{path}.items"), false)?;
}
if let Some(enum_values) = map.get("enum") {
let Some(enum_values) = enum_values.as_array() else {
return Err(format!("{path}: enum must be an array"));
};
if enum_values.is_empty() {
return Err(format!("{path}: enum must not be empty"));
}
for (index, value) in enum_values.iter().enumerate() {
if enum_values
.iter()
.skip(index + 1)
.any(|other| other == value)
{
return Err(format!("{path}: enum entries must be unique"));
}
}
}
Ok(ty)
}
fn is_supported_schema_keyword(key: &str) -> bool {
matches!(
key,
"type"
| "title"
| "description"
| "properties"
| "required"
| "additionalProperties"
| "items"
| "enum"
)
}
#[cfg(test)]
mod tests {
use super::*;
use manifest::plugin::{
PluginDiscoveryOptions, PluginEnablementConfig, PluginExactVersion, PluginGrantConfig,
PluginHttpsGrant, PluginPackageManifest, PluginRuntimeManifest, SourceQualifiedPluginId,
resolve_plugin_config_for_startup,
};
use serde_json::json;
use std::fs;
use std::path::Path;
use std::sync::Mutex;
use tempfile::TempDir;
fn tool(name: &str) -> manifest::plugin::PluginToolManifest {
manifest::plugin::PluginToolManifest {
name: name.into(),
description: format!("{name} tool"),
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
external_write: false,
}
}
fn record(tools: Vec<manifest::plugin::PluginToolManifest>) -> ResolvedPluginRecord {
record_with_identity("project:example", tools)
}
fn record_with_identity(
identity: &str,
tools: Vec<manifest::plugin::PluginToolManifest>,
) -> ResolvedPluginRecord {
let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap();
let permissions = tool_permissions(&tools);
ResolvedPluginRecord {
identity: parsed_identity.clone(),
source: parsed_identity.source,
package_path: std::path::PathBuf::from("/tmp/example.zip"),
package_label: "example.zip".into(),
digest: "sha256:abc".into(),
version: "0.1.0".into(),
manifest: PluginPackageManifest {
schema_version: 1,
id: "example".into(),
name: "Example".into(),
version: "0.1.0".into(),
description: None,
surfaces: vec![PluginSurface::Tool],
runtime: None,
hooks: Vec::new(),
tools,
permissions: permissions.clone(),
},
enabled_surfaces: vec![PluginSurface::Tool],
grants: PluginGrantConfig {
id: Some(parsed_identity.to_string()),
version: Some(PluginExactVersion("0.1.0".to_string())),
digest: Some("sha256:abc".to_string()),
permissions,
https: Vec::new(),
fs: Vec::new(),
},
config: None,
}
}
fn tool_permissions(tools: &[manifest::plugin::PluginToolManifest]) -> Vec<PluginPermission> {
let mut permissions = vec![PluginPermission::surface(PluginSurface::Tool)];
permissions.extend(
tools
.iter()
.map(|tool| PluginPermission::tool(tool.name.clone())),
);
permissions
}
fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize {
report
.reports
.iter()
.map(|feature_report| feature_report.skipped.len())
.sum()
}
fn has_diagnostic(report: &super::super::FeatureRegistryInstallReport, needle: &str) -> bool {
report.reports.iter().any(|feature_report| {
feature_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains(needle))
})
}
fn install_plugin_record(
record: ResolvedPluginRecord,
) -> (
super::super::FeatureRegistryInstallReport,
Vec<ToolDefinition>,
) {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record))
.install_into_pending(&mut pending, &mut hooks);
(report, pending)
}
fn record_with_https_grant() -> ResolvedPluginRecord {
let mut record = record(vec![tool("HttpsTool")]);
let https_permission = PluginPermission::HostApi {
api: PluginHostApi::Https,
};
record.manifest.permissions.push(https_permission.clone());
record.grants.permissions.push(https_permission);
record.grants.https.push(PluginHttpsGrant {
host: "api.example.test".to_string(),
methods: vec!["GET".to_string(), "POST".to_string()],
path_prefixes: vec!["/v1".to_string()],
});
record
}
struct MockHttpsClient {
calls: Mutex<usize>,
response_body: String,
error: Mutex<Option<String>>,
}
impl Default for MockHttpsClient {
fn default() -> Self {
Self {
calls: Mutex::new(0),
response_body: "ok".to_string(),
error: Mutex::new(None),
}
}
}
impl MockHttpsClient {
fn call_count(&self) -> usize {
*self.calls.lock().expect("mock call lock")
}
}
impl PluginHttpsClient for MockHttpsClient {
fn execute(
&self,
_request: &PluginHttpsRequest,
_url: &reqwest::Url,
_limits: PluginHttpsLimits,
) -> Result<PluginHttpsResponse, PluginHttpsError> {
*self.calls.lock().expect("mock call lock") += 1;
if let Some(error) = self.error.lock().expect("mock error lock").take() {
return Err(PluginHttpsError::new(error));
}
Ok(PluginHttpsResponse {
status: 200,
headers: vec![PluginHttpsHeader {
name: "content-type".to_string(),
value: "text/plain".to_string(),
}],
body: self.response_body.clone(),
truncated: false,
})
}
}
struct FakeHttpsResolver {
calls: Mutex<Vec<(String, u16)>>,
addrs: Vec<SocketAddr>,
}
impl FakeHttpsResolver {
fn new(addrs: Vec<SocketAddr>) -> Self {
Self {
calls: Mutex::new(Vec::new()),
addrs,
}
}
fn calls(&self) -> Vec<(String, u16)> {
self.calls.lock().expect("resolver calls lock").clone()
}
}
impl PluginHttpsResolver for FakeHttpsResolver {
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, PluginHttpsError> {
self.calls
.lock()
.expect("resolver calls lock")
.push((host.to_string(), port));
Ok(self.addrs.clone())
}
}
fn https_request_json(method: &str, url: &str) -> String {
json!({ "method": method, "url": url }).to_string()
}
fn wasm_tool_that_calls_https(request: &str) -> Vec<u8> {
let output = br#"{"summary":"https ok","content":"ordinary tool result path"}"#;
wat::parse_str(format!(
r#"
(module
(import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32)))
(import "yoi:https" "request" (func $https_request (param i32 i32) (result i32)))
(import "yoi:https" "response_len" (func $https_response_len (result i32)))
(memory (export "memory") 1)
(data (i32.const 16) "{}")
(data (i32.const 4096) "{}")
(func (export "yoi_tool_call")
(local $n i32)
(local.set $n (call $https_request (i32.const 16) (i32.const {})))
(if (i32.lt_s (local.get $n) (i32.const 0)) (then unreachable))
(drop (call $https_response_len))
(drop (call $output_write (i32.const 4096) (i32.const {})))
)
)
"#,
wat_bytes(request.as_bytes()),
wat_bytes(output),
request.len(),
output.len()
))
.expect("valid wat")
}
fn fs_request_json(path: &str) -> String {
json!({ "path": path }).to_string()
}
fn fs_write_request_json(path: &str, content: &str) -> String {
json!({ "path": path, "content": content }).to_string()
}
fn record_with_fs_grant(
root: &Path,
operations: Vec<PluginFsOperation>,
) -> ResolvedPluginRecord {
let mut record = record(vec![tool("FsTool")]);
let fs_permission = PluginPermission::HostApi {
api: PluginHostApi::Fs,
};
record.manifest.permissions.push(fs_permission.clone());
record.grants.permissions.push(fs_permission);
record.grants.fs.push(PluginFsGrant {
root: root.to_string_lossy().into_owned(),
operations,
});
record
}
fn runtime_record_with_fs_wasm(
wasm: Vec<u8>,
root: &Path,
operations: Vec<PluginFsOperation>,
) -> (TempDir, ResolvedPluginRecord) {
let (dir, mut record) = resolved_record_with_wasm(wasm);
let fs_permission = PluginPermission::HostApi {
api: PluginHostApi::Fs,
};
record.manifest.permissions.push(fs_permission.clone());
record.grants.permissions.push(fs_permission);
record.grants.fs.push(PluginFsGrant {
root: root.to_string_lossy().into_owned(),
operations,
});
(dir, record)
}
fn wasm_tool_that_calls_fs_read(request: &str) -> Vec<u8> {
let output = br#"{"summary":"fs ok","content":"ordinary tool result path"}"#;
wat::parse_str(format!(
r#"
(module
(import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32)))
(import "yoi:fs" "read" (func $fs_read (param i32 i32) (result i32)))
(import "yoi:fs" "response_len" (func $fs_response_len (result i32)))
(import "yoi:fs" "response_read" (func $fs_response_read (param i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 16) "{}")
(data (i32.const 4096) "{}")
(func (export "yoi_tool_call")
(local $n i32)
(local.set $n (call $fs_read (i32.const 16) (i32.const {})))
(if (i32.lt_s (local.get $n) (i32.const 0)) (then unreachable))
(drop (call $fs_response_len))
(drop (call $fs_response_read (i32.const 8192) (i32.const 4096)))
(drop (call $output_write (i32.const 4096) (i32.const {})))
)
)
"#,
wat_bytes(request.as_bytes()),
wat_bytes(output),
request.len(),
output.len()
))
.expect("valid wat")
}
fn empty_wasm_tool() -> Vec<u8> {
let output = br#"{"summary":"no network","content":"no https import"}"#;
wat::parse_str(format!(
r#"
(module
(import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 4096) "{}")
(func (export "yoi_tool_call")
(drop (call $output_write (i32.const 4096) (i32.const {})))
)
)
"#,
wat_bytes(output),
output.len()
))
.expect("valid wat")
}
#[test]
fn rejects_invalid_root_schema() {
let schema = json!({"type":"string"});
assert!(
validate_input_schema(&schema)
.unwrap_err()
.contains("type must be `object`")
);
}
#[test]
fn rejects_unsupported_schema_keyword() {
let schema = json!({"type":"object","oneOf":[]});
assert!(
validate_input_schema(&schema)
.unwrap_err()
.contains("unsupported schema keyword")
);
}
#[test]
fn rejects_invalid_nested_property_schema_node() {
let schema = json!({
"type":"object",
"properties":{"query":"not-a-schema"},
"required":["query"],
"additionalProperties":false
});
let error = validate_input_schema(&schema).unwrap_err();
assert!(error.contains("$.properties.query"));
assert!(error.contains("schema node must be a JSON object"));
}
#[test]
fn rejects_invalid_recursive_schema_members() {
let duplicate_required = json!({
"type":"object",
"properties":{"query":{"type":"string"}},
"required":["query", "query"]
});
assert!(
validate_input_schema(&duplicate_required)
.unwrap_err()
.contains("required entries must be unique")
);
let invalid_items = json!({
"type":"object",
"properties":{"values":{"type":"array", "items":"not-a-schema"}}
});
assert!(
validate_input_schema(&invalid_items)
.unwrap_err()
.contains("$.properties.values.items")
);
let invalid_additional = json!({
"type":"object",
"additionalProperties":{"type":"unsupported"}
});
assert!(
validate_input_schema(&invalid_additional)
.unwrap_err()
.contains("unsupported schema type")
);
}
#[test]
fn accepts_object_tool_schema() {
validate_input_schema(&json!({
"type":"object",
"properties":{
"query":{"type":"string", "description":"Search text"},
"limit":{"type":"integer", "enum":[1, 5, 10]},
"tags":{"type":"array", "items":{"type":"string"}}
},
"required":["query"],
"additionalProperties":{"type":"string"}
}))
.unwrap();
}
#[test]
fn wasm_tool_can_call_granted_fs_read_host_api() {
let root = TempDir::new().expect("temp root");
fs::write(root.path().join("allowed.txt"), "hello fs").expect("write fixture");
let (_dir, record) = runtime_record_with_fs_wasm(
wasm_tool_that_calls_fs_read(&fs_request_json("allowed.txt")),
root.path(),
vec![PluginFsOperation::Read],
);
let output = run_plugin_wasm_tool(record, "PluginEcho".to_string(), Vec::new())
.expect("tool output");
assert_eq!(output.summary, "fs ok");
assert_eq!(output.content.as_deref(), Some("ordinary tool result path"));
}
#[test]
fn granted_fs_read_list_and_write_are_scoped() {
let root = TempDir::new().expect("temp root");
fs::write(root.path().join("read.txt"), "hello fs").expect("write fixture");
fs::create_dir(root.path().join("dir")).expect("create dir");
fs::write(root.path().join("dir").join("entry.txt"), "entry").expect("write entry");
let record = record_with_fs_grant(
root.path(),
vec![
PluginFsOperation::Read,
PluginFsOperation::List,
PluginFsOperation::Write,
],
);
let read = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("read.txt").as_bytes(),
)
.expect("read allowed");
let read: serde_json::Value = serde_json::from_slice(&read).expect("read response json");
assert_eq!(read["content"], "hello fs");
assert_eq!(read["truncated"], false);
let list = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::List,
fs_request_json("dir").as_bytes(),
)
.expect("list allowed");
let list: serde_json::Value = serde_json::from_slice(&list).expect("list response json");
assert_eq!(list["entries"][0]["name"], "entry.txt");
assert_eq!(list["entries"][0]["kind"], "file");
let write = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Write,
fs_write_request_json("written.txt", "new content").as_bytes(),
)
.expect("write allowed");
let write: serde_json::Value = serde_json::from_slice(&write).expect("write response json");
assert_eq!(write["bytes_written"], "new content".len());
assert_eq!(
fs::read_to_string(root.path().join("written.txt")).expect("read written"),
"new content"
);
}
#[test]
fn missing_fs_grant_denies_even_when_manifest_requests_api() {
let root = TempDir::new().expect("temp root");
fs::write(root.path().join("secret.txt"), "must not leak").expect("write fixture");
let mut record = record(vec![tool("FsTool")]);
record.manifest.permissions.push(PluginPermission::HostApi {
api: PluginHostApi::Fs,
});
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("secret.txt").as_bytes(),
)
.expect_err("grant denied");
assert!(error.message.contains("host_api.fs"));
assert!(!error.message.contains("must not leak"));
}
#[test]
fn workspace_scope_is_not_inherited_without_plugin_fs_grant() {
let root = TempDir::new().expect("temp root");
fs::write(root.path().join("workspace.txt"), "workspace authority").expect("write fixture");
let record = record(vec![tool("FsTool")]);
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("workspace.txt").as_bytes(),
)
.expect_err("plugin grant required");
assert!(error.message.contains("host_api.fs"));
assert!(!error.message.contains("workspace authority"));
}
#[test]
fn fs_traversal_and_absolute_paths_are_rejected() {
let root = TempDir::new().expect("temp root");
let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]);
for path in ["../outside.txt", "/etc/passwd"] {
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json(path).as_bytes(),
)
.expect_err("path denied");
assert!(
error.message.contains("relative") || error.message.contains("traversal"),
"unexpected error: {}",
error.message
);
}
}
#[cfg(unix)]
#[test]
fn fs_symlink_escape_is_rejected() {
let root = TempDir::new().expect("temp root");
let outside = TempDir::new().expect("outside root");
fs::write(outside.path().join("outside.txt"), "outside secret").expect("write outside");
std::os::unix::fs::symlink(outside.path(), root.path().join("link"))
.expect("create symlink");
let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]);
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("link/outside.txt").as_bytes(),
)
.expect_err("symlink denied");
assert!(error.message.contains("symlink"));
assert!(!error.message.contains("outside secret"));
}
#[test]
fn fs_read_write_and_list_bounds_are_enforced() {
let root = TempDir::new().expect("temp root");
fs::write(
root.path().join("big.txt"),
vec![b'a'; PLUGIN_FS_MAX_READ_BYTES + 1],
)
.expect("write big file");
let list_dir = root.path().join("many");
fs::create_dir(&list_dir).expect("create list dir");
for index in 0..=PLUGIN_FS_MAX_LIST_ENTRIES {
fs::write(list_dir.join(format!("entry-{index:03}.txt")), "x")
.expect("write list entry");
}
let record = record_with_fs_grant(
root.path(),
vec![
PluginFsOperation::Read,
PluginFsOperation::List,
PluginFsOperation::Write,
],
);
let read = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("big.txt").as_bytes(),
)
.expect("bounded read allowed");
let read: serde_json::Value = serde_json::from_slice(&read).expect("read response json");
assert_eq!(read["truncated"], true);
assert_eq!(
read["content"].as_str().expect("content").len(),
PLUGIN_FS_MAX_READ_BYTES
);
let list = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::List,
fs_request_json("many").as_bytes(),
)
.expect("bounded list allowed");
let list: serde_json::Value = serde_json::from_slice(&list).expect("list response json");
assert_eq!(list["truncated"], true);
assert_eq!(
list["entries"].as_array().expect("entries").len(),
PLUGIN_FS_MAX_LIST_ENTRIES
);
let too_large = "x".repeat(PLUGIN_FS_MAX_WRITE_BYTES + 1);
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Write,
fs_write_request_json("too-large.txt", &too_large).as_bytes(),
)
.expect_err("oversize write denied");
assert!(error.message.contains("exceeds"));
assert!(!root.path().join("too-large.txt").exists());
}
#[test]
fn fs_diagnostics_redact_secret_like_path_segments() {
let root = TempDir::new().expect("temp root");
let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]);
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("secret=my-token-value.txt").as_bytes(),
)
.expect_err("missing file");
assert!(error.message.contains("secret"));
assert!(error.message.contains("<redacted>"));
assert!(!error.message.contains("my-token-value"));
}
#[test]
fn fs_writes_serialize_to_normalized_target() {
let root = TempDir::new().expect("temp root");
let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Write]);
execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Write,
fs_write_request_json("target.txt", "first").as_bytes(),
)
.expect("first write");
execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Write,
fs_write_request_json("./target.txt", "second").as_bytes(),
)
.expect("second write");
assert_eq!(
fs::read_to_string(root.path().join("target.txt")).expect("read target"),
"second"
);
}
#[test]
fn origin_retains_plugin_metadata() {
let feature = PluginToolFeature::new(record(Vec::new()));
let origin = feature.origin();
assert_eq!(origin.kind, "plugin");
assert_eq!(origin.plugin_id, "example");
assert_eq!(origin.plugin_ref, "project:example");
assert_eq!(origin.source, "project");
assert_eq!(origin.digest, "sha256:abc");
assert_eq!(origin.package_version, "0.1.0");
assert_eq!(origin.package_api_version, 1);
assert_eq!(origin.surface, "tool");
}
#[test]
fn wasm_tool_can_call_granted_https_host_api() {
let (_dir, record) = runtime_record_with_https_wasm(wasm_tool_that_calls_https(
&https_request_json("GET", "https://api.example.test/v1/data"),
));
let client = Arc::new(MockHttpsClient::default());
let output = run_plugin_wasm_tool_with_https_client(
record,
"PluginEcho".to_string(),
Vec::new(),
client.clone(),
)
.expect("tool output");
assert_eq!(client.call_count(), 1);
assert_eq!(output.summary, "https ok");
assert_eq!(output.content.as_deref(), Some("ordinary tool result path"));
}
#[test]
fn missing_https_grant_denies_before_network() {
let (_dir, mut record) = resolved_record_with_wasm(wasm_tool_that_calls_https(
&https_request_json("GET", "https://api.example.test/v1/data"),
));
record.manifest.permissions.push(PluginPermission::HostApi {
api: PluginHostApi::Https,
});
let client = Arc::new(MockHttpsClient::default());
let error = run_plugin_wasm_tool_with_https_client(
record,
"PluginEcho".to_string(),
Vec::new(),
client.clone(),
)
.expect_err("grant denied");
assert_eq!(client.call_count(), 0);
assert!(error.bounded_message().contains("host_api.https"));
}
#[test]
fn disallowed_https_request_targets_deny_before_network() {
let record = record_with_https_grant();
let client = MockHttpsClient::default();
for (method, url, needle) in [
("GET", "http://api.example.test/v1/data", "scheme"),
("TRACE", "https://api.example.test/v1/data", "method"),
("GET", "https://other.example.test/v1/data", "grants"),
("GET", "https://localhost/v1/data", "local/private"),
("GET", "https://127.0.0.1/v1/data", "local/private"),
("GET", "https://10.0.0.1/v1/data", "local/private"),
("GET", "https://169.254.169.254/v1/data", "local/private"),
("GET", "file:///tmp/secret", "scheme"),
] {
let error = execute_plugin_https_request(
&record,
&client,
https_request_json(method, url).as_bytes(),
)
.expect_err("request denied");
assert!(
error.0.contains(needle),
"{method} {url} produced {:?}, expected {needle}",
error.0
);
}
assert_eq!(client.call_count(), 0);
}
#[test]
fn ipv4_mapped_ipv6_targets_deny_before_network() {
let record = record_with_https_grant();
let client = MockHttpsClient::default();
for url in [
"https://[::ffff:127.0.0.1]/v1/data",
"https://[::ffff:10.0.0.1]/v1/data",
"https://[::ffff:169.254.169.254]/v1/data",
"https://[::10.0.0.1]/v1/data",
] {
let error = execute_plugin_https_request(
&record,
&client,
https_request_json("GET", url).as_bytes(),
)
.expect_err("mapped address denied");
assert!(
error.0.contains("local/private"),
"{url} produced {:?}",
error.0
);
}
assert_eq!(client.call_count(), 0);
}
#[test]
fn dns_resolution_is_pinned_to_validated_public_socket_addresses() {
let url = reqwest::Url::parse("https://api.example.test:8443/v1/data").unwrap();
let resolver = FakeHttpsResolver::new(vec!["93.184.216.34:8443".parse().unwrap()]);
let pinned = resolve_https_target_for_client(&url, &resolver)
.expect("resolution")
.expect("hostname resolution is pinned");
assert_eq!(
resolver.calls(),
vec![("api.example.test".to_string(), 8443)]
);
assert_eq!(pinned.domains, vec!["api.example.test".to_string()]);
assert_eq!(pinned.addrs, vec!["93.184.216.34:8443".parse().unwrap()]);
let mut builder = reqwest::blocking::Client::builder().no_proxy();
for domain in &pinned.domains {
builder = builder.resolve_to_addrs(domain, &pinned.addrs);
}
builder.build().expect("client accepts pinned resolver");
}
#[test]
fn dns_resolution_rejects_private_addresses_before_client_build() {
let url = reqwest::Url::parse("https://api.example.test/v1/data").unwrap();
let resolver = FakeHttpsResolver::new(vec!["127.0.0.1:443".parse().unwrap()]);
let error =
resolve_https_target_for_client(&url, &resolver).expect_err("private DNS answer");
assert!(error.0.contains("local/private"));
}
#[test]
fn timeout_and_secret_diagnostics_are_bounded_and_redacted() {
let record = record_with_https_grant();
let client = MockHttpsClient::default();
*client.error.lock().expect("mock error lock") =
Some("timeout while using Authorization: Bearer SUPER_SECRET_TOKEN".to_string());
let error = execute_plugin_https_request(
&record,
&client,
https_request_json("GET", "https://api.example.test/v1/data").as_bytes(),
)
.expect_err("timeout error");
assert!(error.0.contains("timeout"));
assert!(error.0.contains(PLUGIN_HTTPS_REDACTION));
assert!(!error.0.contains("SUPER_SECRET_TOKEN"));
assert!(error.0.len() <= 513);
assert_eq!(client.call_count(), 1);
}
#[test]
fn response_size_bound_truncates() {
let record = record_with_https_grant();
let client = MockHttpsClient {
calls: Mutex::new(0),
response_body: "x".repeat(PLUGIN_HTTPS_MAX_RESPONSE_BYTES + 8),
error: Mutex::new(None),
};
let response = execute_plugin_https_request(
&record,
&client,
https_request_json("GET", "https://api.example.test/v1/data").as_bytes(),
)
.expect("response");
let value: Value = serde_json::from_slice(&response).expect("response json");
assert_eq!(value["truncated"], true);
assert_eq!(
value["body"].as_str().expect("body").len(),
PLUGIN_HTTPS_MAX_RESPONSE_BYTES
);
}
#[test]
fn no_network_without_https_import() {
let (_dir, record) = runtime_record_with_https_wasm(empty_wasm_tool());
let client = Arc::new(MockHttpsClient::default());
let output = run_plugin_wasm_tool_with_https_client(
record,
"PluginEcho".to_string(),
Vec::new(),
client.clone(),
)
.expect("tool output");
assert_eq!(client.call_count(), 0);
assert_eq!(output.summary, "no network");
}
#[test]
fn enabled_plugin_tool_registers_model_visible_schema_and_origin() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
.install_into_pending(&mut pending, &mut hooks);
assert!(
report
.reports
.iter()
.all(|feature_report| feature_report.diagnostics.is_empty()),
"{:#?}",
report.reports
);
assert_eq!(report.installed_tool_names(), vec!["PluginSearch"]);
assert_eq!(pending.len(), 1);
let (meta, _) = pending[0]();
assert_eq!(meta.name, "PluginSearch");
assert_eq!(meta.input_schema["type"], "object");
let origin = meta.origin.expect("plugin origin metadata");
assert_eq!(origin.plugin_ref, "project:example");
assert_eq!(origin.digest, "sha256:abc");
assert_eq!(origin.source, "project");
assert_eq!(origin.surface, "tool");
}
#[test]
fn no_grant_denies_plugin_tool_registration_and_runtime_execution() {
let mut record = record(vec![tool("PluginSearch")]);
record.grants = PluginGrantConfig::default();
let (report, pending) = install_plugin_record(record.clone());
assert!(pending.is_empty());
assert!(has_diagnostic(&report, "registration denied"));
assert!(has_diagnostic(
&report,
"granted surfaces.tool permission is missing"
));
let error = run_plugin_wasm_tool(record, "PluginSearch".into(), br#"{}"#.to_vec())
.unwrap_err()
.bounded_message();
assert!(error.contains("plugin permission denied"), "{error}");
assert!(
error.contains("granted surfaces.tool permission is missing"),
"{error}"
);
assert!(error.len() < 700, "{error}");
}
#[test]
fn specific_tool_grant_registers_only_intended_plugin_tool() {
let mut record = record(vec![tool("PluginAllowed"), tool("PluginDenied")]);
record.grants.permissions = vec![
PluginPermission::surface(PluginSurface::Tool),
PluginPermission::tool("PluginAllowed"),
];
let (report, pending) = install_plugin_record(record);
assert_eq!(pending.len(), 1);
let (meta, _) = pending[0]();
assert_eq!(meta.name, "PluginAllowed");
assert_eq!(report.installed_tool_names(), vec!["PluginAllowed"]);
assert!(has_diagnostic(
&report,
"granted tool permission for `PluginDenied` is missing"
));
}
#[test]
fn grant_binding_mismatches_do_not_authorize_plugin_tool() {
let mut unrelated = record(vec![tool("PluginSearch")]);
unrelated.grants.id = Some("project:other".to_string());
let error = authorize_plugin_tool(&unrelated, &unrelated.manifest.tools[0])
.unwrap_err()
.bounded_message();
assert!(
error.contains("package id binding does not match"),
"{error}"
);
let mut bad_digest = record(vec![tool("PluginSearch")]);
bad_digest.grants.digest = Some("sha256:not-the-package".to_string());
let error = authorize_plugin_tool(&bad_digest, &bad_digest.manifest.tools[0])
.unwrap_err()
.bounded_message();
assert!(error.contains("digest binding does not match"), "{error}");
let mut bad_version = record(vec![tool("PluginSearch")]);
bad_version.grants.version = Some(PluginExactVersion("9.9.9".to_string()));
let error = authorize_plugin_tool(&bad_version, &bad_version.manifest.tools[0])
.unwrap_err()
.bounded_message();
assert!(error.contains("version binding does not match"), "{error}");
}
#[test]
fn requested_surface_tool_and_external_write_permissions_are_required() {
let mut missing_surface = record(vec![tool("PluginSearch")]);
missing_surface.manifest.permissions = vec![PluginPermission::tool("PluginSearch")];
let (report, pending) = install_plugin_record(missing_surface);
assert!(pending.is_empty());
assert!(has_diagnostic(
&report,
"requested surfaces.tool permission is missing"
));
let mut missing_tool = record(vec![tool("PluginSearch")]);
missing_tool.manifest.permissions = vec![PluginPermission::surface(PluginSurface::Tool)];
let (report, pending) = install_plugin_record(missing_tool);
assert!(pending.is_empty());
assert!(has_diagnostic(
&report,
"requested tool permission for `PluginSearch` is missing"
));
let mut external_tool = tool("PluginWrite");
external_tool.external_write = true;
let mut missing_external_request = record(vec![external_tool]);
let (report, pending) = install_plugin_record(missing_external_request.clone());
assert!(pending.is_empty());
assert!(has_diagnostic(
&report,
"requested external_write permission is missing"
));
missing_external_request
.manifest
.permissions
.push(PluginPermission::ExternalWrite);
let (report, pending) = install_plugin_record(missing_external_request);
assert!(pending.is_empty());
assert!(has_diagnostic(
&report,
"granted external_write permission is missing"
));
}
#[test]
fn future_host_api_imports_are_permission_checked_before_unimplemented_boundary() {
let (_dir, mut record) = resolved_record_with_wasm(https_import_module());
let error = run_plugin_wasm_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec())
.unwrap_err()
.bounded_message();
assert!(
error.contains("requested host_api.https permission is missing"),
"{error}"
);
record
.manifest
.permissions
.push(PluginPermission::host_api(PluginHostApi::Https));
record
.grants
.permissions
.push(PluginPermission::host_api(PluginHostApi::Https));
let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec())
.unwrap_err()
.bounded_message();
assert!(
error.contains("granted host_api.https allowlist is missing"),
"{error}"
);
}
#[test]
fn package_without_enabled_tool_surface_registers_no_schema() {
let mut config = PluginConfig::default();
let mut disabled = record(vec![tool("PluginSearch")]);
disabled.enabled_surfaces.clear();
config.resolved.push(disabled);
assert!(plugin_tool_features(&config).is_empty());
}
#[test]
fn disabled_profile_feature_registers_no_schema() {
let mut config = PluginConfig::default();
config.resolved.push(record(vec![tool("PluginSearch")]));
assert!(plugin_tool_features_if_enabled(false, &config).is_empty());
assert_eq!(plugin_tool_features_if_enabled(true, &config).len(), 1);
}
#[test]
fn duplicate_plugin_tool_names_are_rejected_with_diagnostic() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
.with_module(PluginToolFeature::new(record_with_identity(
"project:other",
vec![tool("PluginSearch")],
)))
.install_into_pending(&mut pending, &mut hooks);
assert_eq!(pending.len(), 1);
assert_eq!(skipped_count(&report), 1);
assert!(has_diagnostic(&report, "duplicate tool contribution"));
}
#[test]
fn builtin_tool_name_collision_is_rejected_with_diagnostic() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let mut registered = std::collections::HashMap::new();
registered.insert("Read".to_string(), FeatureId::builtin("preexisting-tool"));
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("Read")])))
.install_into_pending_with_registered(&mut pending, &mut hooks, registered);
assert!(pending.is_empty());
assert_eq!(skipped_count(&report), 1);
assert!(has_diagnostic(&report, "duplicate tool contribution"));
}
#[test]
fn invalid_input_schema_is_rejected_with_diagnostic() {
let mut invalid = tool("BadSchema");
invalid.input_schema = json!({"type":"object","$ref":"#/defs/input"});
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![invalid])))
.install_into_pending(&mut pending, &mut hooks);
assert!(pending.is_empty());
assert!(has_diagnostic(&report, "invalid input_schema"));
}
#[test]
fn nested_invalid_input_schema_does_not_register_plugin_tool() {
let mut invalid = tool("BadNestedSchema");
invalid.input_schema = json!({
"type":"object",
"properties":{"query":"not-a-schema"},
"required":["query"],
"additionalProperties":false
});
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![invalid])))
.install_into_pending(&mut pending, &mut hooks);
assert!(pending.is_empty());
assert!(has_diagnostic(&report, "invalid input_schema"));
assert!(has_diagnostic(&report, "$.properties.query"));
}
#[tokio::test]
async fn registered_plugin_tool_executes_wasm_and_returns_normal_tool_result() {
let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module());
let origin = PluginToolFeature::new(record.clone()).origin();
let tool = PluginWasmTool {
record,
name: "PluginEcho".into(),
origin,
};
let output = tool
.execute(r#"{"x":1}"#, ToolExecutionContext::default())
.await
.unwrap();
assert_eq!(output.summary, "input reached");
assert_eq!(output.content.as_deref(), Some("ordinary tool result path"));
let result = llm_worker::tool::ToolResult::from_output("call-1", output);
assert_eq!(result.summary, "input reached");
assert!(
result
.content
.unwrap()
.contains("ordinary tool result path")
);
}
#[test]
fn pdk_tool_output_shape_is_accepted_by_wasm_decoder() {
let pdk_output =
yoi_plugin_pdk::ToolOutput::json("pdk ok", serde_json::json!({"answer": 42}))
.unwrap()
.to_json_string();
let output = decode_plugin_wasm_output(pdk_output.as_bytes()).unwrap();
assert_eq!(output.summary, "pdk ok");
assert_eq!(output.content.as_deref(), Some(r#"{"answer":42}"#));
}
#[tokio::test]
async fn malformed_input_json_fails_before_wasm_execution() {
let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module());
let origin = PluginToolFeature::new(record.clone()).origin();
let tool = PluginWasmTool {
record,
name: "PluginEcho".into(),
origin,
};
let error = tool
.execute("not json", ToolExecutionContext::default())
.await
.unwrap_err();
assert!(error.to_string().contains("input is not valid JSON"));
}
#[tokio::test]
async fn malformed_output_fails_closed() {
let (_dir, record) = resolved_record_with_wasm(output_module(b"not json"));
let error =
run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err();
assert!(error.bounded_message().contains("not valid JSON"));
}
#[tokio::test]
async fn schema_mismatch_output_fails_closed() {
let (_dir, record) = resolved_record_with_wasm(output_module(br#"{"summary":1}"#));
let error =
run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err();
assert!(error.bounded_message().contains("summary must be a string"));
}
#[tokio::test]
async fn oversize_output_fails_closed() {
let (_dir, record) = resolved_record_with_wasm(oversize_output_module());
let error =
run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err();
assert!(error.bounded_message().contains("exceeds"));
}
#[tokio::test]
async fn nonterminating_execution_fails_closed_with_fuel_boundary() {
let (_dir, record) = resolved_record_with_wasm(nonterminating_module());
let error =
run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err();
let message = error.bounded_message();
assert!(
message.contains("Execution")
|| message.contains("fuel")
|| message.contains("execution"),
"{message}"
);
}
#[tokio::test]
async fn missing_runtime_module_returns_safe_bounded_tool_error() {
let record = record_with_missing_package_runtime();
let origin = PluginToolFeature::new(record.clone()).origin();
let tool = PluginWasmTool {
record,
name: "PluginSearch".into(),
origin,
};
let error = tool
.execute("{}", ToolExecutionContext::default())
.await
.unwrap_err();
let message = error.to_string();
assert!(message.contains("failed closed"));
assert!(message.contains("metadata could not be read"));
assert!(message.len() < 900);
assert!(message.contains("project:example"));
}
#[tokio::test]
async fn ambient_wasi_fs_network_env_imports_are_unavailable() {
let (_dir, record) = resolved_record_with_wasm(wasi_import_module());
let error =
run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err();
let message = error.bounded_message();
assert!(message.contains("unsupported import module"), "{message}");
assert!(message.contains("wasi_snapshot_preview1"), "{message}");
}
fn record_with_missing_package_runtime() -> ResolvedPluginRecord {
let mut record = record(vec![tool("PluginSearch")]);
record.manifest.runtime = Some(PluginRuntimeManifest {
kind: PLUGIN_RUNTIME_WASM_KIND.into(),
entry: Some("plugin.wasm".into()),
abi: Some(PLUGIN_RUNTIME_WASM_ABI.into()),
component: None,
world: None,
});
record
}
fn runtime_record_with_https_wasm(wasm: Vec<u8>) -> (TempDir, ResolvedPluginRecord) {
let (dir, mut record) = resolved_record_with_wasm(wasm);
let https_permission = PluginPermission::HostApi {
api: PluginHostApi::Https,
};
record.manifest.permissions.push(https_permission.clone());
record.grants.permissions.push(https_permission);
record.grants.https.push(PluginHttpsGrant {
host: "api.example.test".to_string(),
methods: vec!["GET".to_string(), "POST".to_string()],
path_prefixes: vec!["/v1".to_string()],
});
(dir, record)
}
fn resolved_record_with_wasm(wasm: Vec<u8>) -> (TempDir, ResolvedPluginRecord) {
let dir = TempDir::new().unwrap();
let package_dir = dir.path().join(".yoi/plugins");
fs::create_dir_all(&package_dir).unwrap();
let package_path = package_dir.join("example.yoi-plugin");
write_plugin_package(&package_path, &wasm);
let config = PluginConfig {
enabled: vec![PluginEnablementConfig {
id: "project:example".parse().unwrap(),
surfaces: vec![PluginSurface::Tool],
..PluginEnablementConfig::default()
}],
resolved: Vec::new(),
diagnostics: Vec::new(),
};
let options = PluginDiscoveryOptions::new(dir.path());
let resolved = resolve_plugin_config_for_startup(&config, &options);
assert!(
resolved.diagnostics.is_empty(),
"{:#?}",
resolved.diagnostics
);
assert_eq!(resolved.resolved.len(), 1);
let mut record = resolved.resolved[0].clone();
record.grants = PluginGrantConfig {
id: Some(record.identity.to_string()),
version: Some(PluginExactVersion(record.version.clone())),
digest: Some(record.digest.clone()),
permissions: tool_permissions(&record.manifest.tools),
https: Vec::new(),
fs: Vec::new(),
};
(dir, record)
}
fn write_component_plugin_package(path: &Path, component: &[u8], world: &str) {
let manifest = format!(
r#"schema_version = 1
id = "example"
name = "Example"
version = "1.0.0"
description = "Example component plugin"
surfaces = ["tool"]
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "{}"
[[permissions]]
kind = "surface"
surface = "tool"
[[permissions]]
kind = "tool"
name = "PluginEcho"
[[tools]]
name = "PluginEcho"
description = "Echo plugin tool"
input_schema = {{ type = "object", additionalProperties = true }}
"#,
world
);
write_stored_zip(
path,
&[
("plugin.toml", manifest.as_bytes()),
("plugin.component.wasm", component),
],
);
}
fn resolved_record_with_component(component: Vec<u8>) -> (TempDir, ResolvedPluginRecord) {
let dir = TempDir::new().unwrap();
let package_dir = dir.path().join(".yoi/plugins");
fs::create_dir_all(&package_dir).unwrap();
let package_path = package_dir.join("component.yoi-plugin");
write_component_plugin_package(&package_path, &component, PLUGIN_COMPONENT_TOOL_WORLD);
let config = PluginConfig {
enabled: vec![PluginEnablementConfig {
id: "project:example".parse().unwrap(),
surfaces: vec![PluginSurface::Tool],
..PluginEnablementConfig::default()
}],
resolved: Vec::new(),
diagnostics: Vec::new(),
};
let options = PluginDiscoveryOptions::new(dir.path());
let resolved = resolve_plugin_config_for_startup(&config, &options);
assert!(
resolved.diagnostics.is_empty(),
"{:#?}",
resolved.diagnostics
);
assert_eq!(resolved.resolved.len(), 1);
let mut record = resolved.resolved[0].clone();
record.grants = PluginGrantConfig {
id: Some(record.identity.to_string()),
version: Some(PluginExactVersion(record.version.clone())),
digest: Some(record.digest.clone()),
permissions: tool_permissions(&record.manifest.tools),
https: Vec::new(),
fs: Vec::new(),
};
(dir, record)
}
fn component_tool_that_returns(output: &[u8]) -> Vec<u8> {
component_tool_with_memory_pages(output, 1)
}
fn component_tool_with_memory_pages(output: &[u8], memory_pages: usize) -> Vec<u8> {
wat::parse_str(format!(
r#"(component
(core module $m
(memory (export "memory") {})
(func (export "realloc") (param i32 i32 i32 i32) (result i32)
(if (result i32) (i32.eqz (local.get 0))
(then (i32.const 8192))
(else (local.get 0))))
(data (i32.const 1024) "{}")
(func (export "call") (param i32 i32 i32 i32) (result i32)
(i32.store (i32.const 2048) (i32.const 1024))
(i32.store (i32.const 2052) (i32.const {}))
(i32.const 2048))
)
(core instance $i (instantiate $m))
(alias core export $i "memory" (core memory $mem))
(alias core export $i "realloc" (core func $realloc))
(alias core export $i "call" (core func $call_core))
(type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string)))
(func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(export "call" (func $call))
)"#,
memory_pages,
wat_bytes(output),
output.len()
))
.expect("valid component wat")
}
fn component_tool_with_table_elements(output: &[u8], table_elements: usize) -> Vec<u8> {
wat::parse_str(format!(
r#"(component
(core module $m
(memory (export "memory") 1)
(table {} funcref)
(func (export "realloc") (param i32 i32 i32 i32) (result i32)
(if (result i32) (i32.eqz (local.get 0))
(then (i32.const 8192))
(else (local.get 0))))
(data (i32.const 1024) "{}")
(func (export "call") (param i32 i32 i32 i32) (result i32)
(i32.store (i32.const 2048) (i32.const 1024))
(i32.store (i32.const 2052) (i32.const {}))
(i32.const 2048))
)
(core instance $i (instantiate $m))
(alias core export $i "memory" (core memory $mem))
(alias core export $i "realloc" (core func $realloc))
(alias core export $i "call" (core func $call_core))
(type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string)))
(func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(export "call" (func $call))
)"#,
table_elements,
wat_bytes(output),
output.len()
))
.expect("valid component wat")
}
fn component_tool_importing_https(output: &[u8]) -> Vec<u8> {
wat::parse_str(format!(
r#"(component
(import "yoi:host/https@1.0.0" (instance $https (export "request" (func $request (param "request-json" string) (result string)))))
(core module $m
(memory (export "memory") 1)
(func (export "realloc") (param i32 i32 i32 i32) (result i32)
(if (result i32) (i32.eqz (local.get 0))
(then (i32.const 8192))
(else (local.get 0))))
(data (i32.const 1024) "{}")
(func (export "call") (param i32 i32 i32 i32) (result i32)
(i32.store (i32.const 2048) (i32.const 1024))
(i32.store (i32.const 2052) (i32.const {}))
(i32.const 2048))
)
(core instance $i (instantiate $m))
(alias core export $i "memory" (core memory $mem))
(alias core export $i "realloc" (core func $realloc))
(alias core export $i "call" (core func $call_core))
(type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string)))
(func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(export "call" (func $call))
)"#,
wat_bytes(output),
output.len()
))
.expect("valid component wat")
}
fn component_without_call_export() -> Vec<u8> {
wat::parse_str(r#"(component (core module $m) (core instance $i (instantiate $m)))"#)
.unwrap()
}
fn raw_module_bytes() -> Vec<u8> {
wat::parse_str(r#"(module (func (export "call")))"#).unwrap()
}
#[test]
fn component_tool_executes_through_ordinary_tool_result_path() {
let (_dir, record) = resolved_record_with_component(component_tool_that_returns(
br#"{"summary":"component ok","content":"ordinary tool result path"}"#,
));
let output = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect("component tool output");
assert_eq!(output.summary, "component ok");
assert_eq!(output.content.as_deref(), Some("ordinary tool result path"));
}
#[test]
fn component_memory_limit_fails_closed_before_string_lift() {
let oversized_memory_pages = (PLUGIN_WASM_MEMORY_BYTES / 65_536) + 1;
let (_dir, record) = resolved_record_with_component(component_tool_with_memory_pages(
br#"{"summary":"should not lift"}"#,
oversized_memory_pages,
));
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("component memory limit is enforced");
assert!(format!("{error:?}").contains("growing memory"), "{error:?}");
}
#[test]
fn component_table_limit_fails_closed() {
let (_dir, record) = resolved_record_with_component(component_tool_with_table_elements(
br#"{"summary":"should not run"}"#,
PLUGIN_WASM_TABLE_ELEMENTS + 1,
));
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("component table limit is enforced");
assert!(format!("{error:?}").contains("growing table"), "{error:?}");
}
#[test]
fn component_output_cap_still_fails_closed_after_bounded_lift() {
let output = format!(
r#"{{"summary":"too big","content":"{}"}}"#,
"x".repeat(PLUGIN_WASM_MAX_OUTPUT_BYTES)
);
let (_dir, record) =
resolved_record_with_component(component_tool_with_memory_pages(output.as_bytes(), 2));
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("component output cap is enforced");
assert!(format!("{error:?}").contains("output exceeds"), "{error:?}");
}
#[test]
fn component_tool_denies_host_import_without_matching_grant() {
let (_dir, record) = resolved_record_with_component(component_tool_importing_https(
br#"{"summary":"component ok"}"#,
));
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("host import without grant is denied");
assert!(
format!("{error:?}").contains("plugin host API dispatch denied"),
"{error:?}"
);
}
#[test]
fn component_tool_missing_export_fails_closed() {
let (_dir, record) = resolved_record_with_component(component_without_call_export());
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("missing export fails closed");
assert!(
format!("{error:?}").contains("does not export expected"),
"{error:?}"
);
}
#[test]
fn core_wasm_is_not_silently_reinterpreted_as_component() {
let (_dir, record) = resolved_record_with_component(raw_module_bytes());
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("core module is incompatible with component runtime");
assert!(
format!("{error:?}").contains("component is incompatible"),
"{error:?}"
);
}
#[test]
fn component_wrong_world_fails_closed_during_discovery() {
let dir = TempDir::new().unwrap();
let package_dir = dir.path().join(".yoi/plugins");
fs::create_dir_all(&package_dir).unwrap();
let package_path = package_dir.join("component.yoi-plugin");
write_component_plugin_package(
&package_path,
&component_tool_that_returns(br#"{"summary":"component ok"}"#),
"example:other/world@1.0.0",
);
let config = PluginConfig {
enabled: vec![PluginEnablementConfig {
id: "project:example".parse().unwrap(),
surfaces: vec![PluginSurface::Tool],
..PluginEnablementConfig::default()
}],
resolved: Vec::new(),
diagnostics: Vec::new(),
};
let options = PluginDiscoveryOptions::new(dir.path());
let resolved = resolve_plugin_config_for_startup(&config, &options);
assert!(resolved.resolved.is_empty());
assert!(
resolved.diagnostics.iter().any(|diagnostic| diagnostic
.message
.contains("component world is unsupported")),
"{:#?}",
resolved.diagnostics
);
}
#[test]
fn component_tool_registration_uses_existing_tool_registry_path() {
let (_dir, record) = resolved_record_with_component(component_tool_that_returns(
br#"{"summary":"component ok"}"#,
));
let (report, pending) = install_plugin_record(record);
assert_eq!(skipped_count(&report), 0, "{report:#?}");
assert_eq!(pending.len(), 1);
let (meta, _) = pending[0]();
assert_eq!(meta.name, "PluginEcho");
}
#[test]
fn component_static_inspection_reports_component_runtime_without_execution() {
let mut record = record(vec![tool("Echo")]);
record.package_path = std::path::PathBuf::from("/no/such/component.wasm");
record.manifest.runtime = Some(PluginRuntimeManifest {
kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(),
entry: None,
abi: None,
component: Some("plugin.component.wasm".to_string()),
world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()),
});
let inspection = inspect_resolved_plugin_static(&record);
assert!(inspection.runtime.eligible);
assert_eq!(
inspection.runtime.status,
format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}")
);
assert!(inspection.runtime.diagnostic.is_none());
}
fn write_plugin_package(path: &Path, wasm: &[u8]) {
let manifest = br#"schema_version = 1
id = "example"
name = "Example"
version = "1.0.0"
description = "Example plugin"
surfaces = ["tool"]
[runtime]
kind = "wasm"
entry = "plugin.wasm"
abi = "yoi-plugin-wasm-1"
[[permissions]]
kind = "surface"
surface = "tool"
[[permissions]]
kind = "tool"
name = "PluginEcho"
[[tools]]
name = "PluginEcho"
description = "Echo plugin tool"
input_schema = { type = "object", additionalProperties = true }
"#;
write_stored_zip(
path,
&[("plugin.toml", manifest.as_slice()), ("plugin.wasm", wasm)],
);
}
fn input_reaches_guest_module() -> Vec<u8> {
let ok = br#"{"summary":"input reached","content":"ordinary tool result path"}"#;
let bad = br#"{"summary":"input missing"}"#;
let wat = format!(
r#"(module
(import "yoi:tool" "input_len" (func $input_len (result i32)))
(import "yoi:tool" "input_read" (func $input_read (param i32 i32) (result i32)))
(import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "{}")
(data (i32.const 128) "{}")
(func (export "yoi_tool_call")
(local $len i32)
(local.set $len (call $input_len))
(if (i32.eq (local.get $len) (i32.const 7))
(then
(drop (call $input_read (i32.const 512) (local.get $len)))
(if (i32.eq (i32.load8_u (i32.const 517)) (i32.const 49))
(then (drop (call $output_write (i32.const 0) (i32.const {}))))
(else (drop (call $output_write (i32.const 128) (i32.const {}))))
)
)
(else (drop (call $output_write (i32.const 128) (i32.const {}))))
)
)
)"#,
wat_bytes(ok),
wat_bytes(bad),
ok.len(),
bad.len(),
bad.len()
);
wat::parse_str(wat).unwrap()
}
fn output_module(output: &[u8]) -> Vec<u8> {
let wat = format!(
r#"(module
(import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "{}")
(func (export "yoi_tool_call")
(drop (call $output_write (i32.const 0) (i32.const {})))
)
)"#,
wat_bytes(output),
output.len()
);
wat::parse_str(wat).unwrap()
}
fn oversize_output_module() -> Vec<u8> {
let wat = format!(
r#"(module
(import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32)))
(memory (export "memory") 2)
(func (export "yoi_tool_call")
(drop (call $output_write (i32.const 0) (i32.const {})))
)
)"#,
PLUGIN_WASM_MAX_OUTPUT_BYTES + 1
);
wat::parse_str(wat).unwrap()
}
fn nonterminating_module() -> Vec<u8> {
wat::parse_str(
r#"(module
(memory (export "memory") 1)
(func (export "yoi_tool_call")
(local $remaining i32)
(local.set $remaining (i32.const 100000000))
(loop $again
(local.set $remaining (i32.sub (local.get $remaining) (i32.const 1)))
(br_if $again (local.get $remaining))
)
)
)"#,
)
.unwrap()
}
fn wasi_import_module() -> Vec<u8> {
wat::parse_str(
r#"(module
(import "wasi_snapshot_preview1" "fd_write" (func $fd_write))
(memory (export "memory") 1)
(func (export "yoi_tool_call"))
)"#,
)
.unwrap()
}
fn https_import_module() -> Vec<u8> {
wat::parse_str(
r#"(module
(import "yoi:https" "request" (func $request))
(memory (export "memory") 1)
(func (export "yoi_tool_call"))
)"#,
)
.unwrap()
}
fn wat_bytes(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!(r#"\{:02x}"#, byte))
.collect()
}
#[test]
fn static_inspection_does_not_read_or_execute_package() {
let mut record = record(vec![tool("Echo")]);
record.package_path = std::path::PathBuf::from("/no/such/plugin.wasm");
record.manifest.runtime = Some(PluginRuntimeManifest {
kind: PLUGIN_RUNTIME_WASM_KIND.to_string(),
entry: Some("plugin.wasm".to_string()),
abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()),
component: None,
world: None,
});
let inspection = inspect_resolved_plugin_static(&record);
assert!(inspection.runtime.eligible);
assert_eq!(inspection.tools.len(), 1);
assert!(inspection.tools[0].eligible);
assert!(inspection.statically_eligible());
}
#[test]
fn static_inspection_reports_missing_tool_grant() {
let mut record = record(vec![tool("Echo")]);
record.manifest.runtime = Some(PluginRuntimeManifest {
kind: PLUGIN_RUNTIME_WASM_KIND.to_string(),
entry: Some("plugin.wasm".to_string()),
abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()),
component: None,
world: None,
});
record.grants.permissions = vec![PluginPermission::surface(PluginSurface::Tool)];
let inspection = inspect_resolved_plugin_static(&record);
assert!(!inspection.statically_eligible());
assert!(!inspection.tools[0].eligible);
assert!(
inspection.tools[0]
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("grant")
);
}
#[test]
fn static_inspection_reports_invalid_tool_definition() {
let mut bad_schema = tool("Echo");
bad_schema.input_schema = json!({"type":"string"});
let mut record = record(vec![bad_schema]);
record.manifest.runtime = Some(PluginRuntimeManifest {
kind: PLUGIN_RUNTIME_WASM_KIND.to_string(),
entry: Some("plugin.wasm".to_string()),
abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()),
component: None,
world: None,
});
let inspection = inspect_resolved_plugin_static(&record);
assert!(!inspection.statically_eligible());
assert!(!inspection.tools[0].eligible);
let diagnostic = inspection.tools[0]
.diagnostic
.as_deref()
.unwrap_or_default();
assert!(diagnostic.contains("invalid input_schema"));
assert!(diagnostic.contains("root schema type must be `object`"));
}
#[test]
fn static_inspection_reports_invalid_and_duplicate_tool_names() {
let mut invalid = tool("Bad Tool");
invalid.input_schema = json!({"type":"object"});
let mut first_duplicate = tool("Echo");
let mut second_duplicate = tool("Echo");
first_duplicate.input_schema = json!({"type":"object"});
second_duplicate.input_schema = json!({"type":"object"});
let mut record = record(vec![invalid, first_duplicate, second_duplicate]);
record.manifest.runtime = Some(PluginRuntimeManifest {
kind: PLUGIN_RUNTIME_WASM_KIND.to_string(),
entry: Some("plugin.wasm".to_string()),
abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()),
component: None,
world: None,
});
let inspection = inspect_resolved_plugin_static(&record);
assert!(!inspection.statically_eligible());
assert!(
inspection.tools[0]
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("invalid name")
);
assert!(
inspection.tools[1]
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("duplicate name")
);
assert!(
inspection.tools[2]
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("duplicate name")
);
}
fn write_stored_zip(path: &Path, files: &[(&str, &[u8])]) {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in files {
let offset = out.len() as u32;
let name_bytes = name.as_bytes();
let crc = crc32(data);
write_u32(&mut out, 0x0403_4b50);
write_u16(&mut out, 20);
write_u16(&mut out, 0);
write_u16(&mut out, 0);
write_u16(&mut out, 0);
write_u16(&mut out, 0);
write_u32(&mut out, crc);
write_u32(&mut out, data.len() as u32);
write_u32(&mut out, data.len() as u32);
write_u16(&mut out, name_bytes.len() as u16);
write_u16(&mut out, 0);
out.extend_from_slice(name_bytes);
out.extend_from_slice(data);
write_u32(&mut central, 0x0201_4b50);
write_u16(&mut central, 20);
write_u16(&mut central, 20);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u32(&mut central, crc);
write_u32(&mut central, data.len() as u32);
write_u32(&mut central, data.len() as u32);
write_u16(&mut central, name_bytes.len() as u16);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u32(&mut central, 0);
write_u32(&mut central, offset);
central.extend_from_slice(name_bytes);
}
let central_offset = out.len() as u32;
let central_size = central.len() as u32;
out.extend_from_slice(&central);
write_u32(&mut out, 0x0605_4b50);
write_u16(&mut out, 0);
write_u16(&mut out, 0);
write_u16(&mut out, files.len() as u16);
write_u16(&mut out, files.len() as u16);
write_u32(&mut out, central_size);
write_u32(&mut out, central_offset);
write_u16(&mut out, 0);
fs::write(path, out).unwrap();
}
fn write_u16(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn write_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xffff_ffffu32;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
let mask = if crc & 1 == 1 { 0xedb8_8320 } else { 0 };
crc = (crc >> 1) ^ mask;
}
}
!crc
}
}