diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index b005752b..1ca3f2e7 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -45,6 +45,12 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ "../../../resources/plugin/templates/rust-component-tool/plugin.toml" ), }, + PluginTemplateResource { + path: "plugin.component.wasm", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-tool/plugin.component.wasm" + ), + }, PluginTemplateResource { path: "README.md", contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"), @@ -518,6 +524,23 @@ pub struct DiscoveredPluginPackage { pub entries: BTreeSet, } +/// Fully materialized package content used by local authoring checks and pack. +/// +/// This is data-only metadata and bytes. Constructing it parses manifests and +/// validates package/archive shape, but it does not load, instantiate, or +/// execute Plugin code. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MaterializedPluginPackage { + pub package: DiscoveredPluginPackage, + pub files: BTreeMap>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackedPluginPackage { + pub output_path: PathBuf, + pub package: DiscoveredPluginPackage, +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PluginDiscoveryReport { pub packages: Vec, @@ -1362,7 +1385,146 @@ fn read_package( .with_source(source) .with_package(label) })?; - let archive = parse_stored_zip(&bytes, label, source, limits)?; + materialize_archive(path, label, source, &bytes, limits) + .map(|materialized| materialized.package) +} + +pub fn read_plugin_package_file( + path: &Path, + source: PluginSourceKind, + limits: &PluginDiscoveryLimits, +) -> Result { + let label = package_label(path); + let metadata = fs::metadata(path).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "plugin package metadata could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(source) + .with_package(label.clone()) + })?; + if !metadata.is_file() { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Malformed, + PluginDiagnosticPhase::Discovery, + "plugin package candidate is not a regular file", + ) + .with_source(source) + .with_package(label)); + } + if metadata.len() > limits.max_package_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Discovery, + "plugin package exceeds the configured package size bound", + ) + .with_source(source) + .with_package(label)); + } + let bytes = fs::read(path).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "plugin package content could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(source) + .with_package(label.clone()) + })?; + materialize_archive(path, &label, source, &bytes, limits) +} + +pub fn read_plugin_directory( + path: &Path, + source: PluginSourceKind, + limits: &PluginDiscoveryLimits, +) -> Result { + let label = package_label(path); + let root = fs::canonicalize(path).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "plugin directory could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(source) + .with_package(label.clone()) + })?; + let metadata = fs::metadata(&root).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "plugin directory metadata could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(source) + .with_package(label.clone()) + })?; + if !metadata.is_dir() { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Malformed, + PluginDiagnosticPhase::Discovery, + "plugin directory input is not a directory", + ) + .with_source(source) + .with_package(label)); + } + + let mut files = BTreeMap::new(); + collect_directory_files(&root, &root, &label, source, limits, &mut files)?; + materialize_files(path, label, source, files, limits) +} + +pub fn write_plugin_package_file( + materialized: &MaterializedPluginPackage, + output_path: &Path, + limits: &PluginDiscoveryLimits, +) -> Result { + write_stored_zip_file(output_path, &materialized.files, limits)?; + let package = read_plugin_package_file(output_path, materialized.package.source(), limits)?; + Ok(PackedPluginPackage { + output_path: output_path.to_path_buf(), + package: package.package, + }) +} + +impl DiscoveredPluginPackage { + pub fn source(&self) -> PluginSourceKind { + self.identity.source + } +} + +fn materialize_archive( + path: &Path, + label: &str, + source: PluginSourceKind, + bytes: &[u8], + limits: &PluginDiscoveryLimits, +) -> Result { + let archive = parse_stored_zip(bytes, label, source, limits)?; + materialize_files(path, label.to_string(), source, archive.files, limits) +} + +fn materialize_files( + path: &Path, + label: String, + source: PluginSourceKind, + files: BTreeMap>, + limits: &PluginDiscoveryLimits, +) -> Result { + let archive = StoredArchive { + files: files.clone(), + }; let manifest_bytes = archive.files.get("plugin.toml").ok_or_else(|| { PluginDiagnostic::new( PluginDiagnosticKind::Missing, @@ -1370,7 +1532,7 @@ fn read_package( "plugin package is missing root plugin.toml", ) .with_source(source) - .with_package(label) + .with_package(label.clone()) })?; if manifest_bytes.len() > limits.max_manifest_size_bytes { return Err(PluginDiagnostic::new( @@ -1388,7 +1550,7 @@ fn read_package( "plugin.toml is not valid UTF-8", ) .with_source(source) - .with_package(label) + .with_package(label.clone()) })?; let manifest: PluginPackageManifest = toml::from_str(manifest_text).map_err(|error| { PluginDiagnostic::new( @@ -1397,20 +1559,282 @@ fn read_package( safe_toml_parse_message(&error), ) .with_source(source) - .with_package(label) + .with_package(label.clone()) })?; - validate_manifest(&manifest, &archive, label, source)?; + validate_manifest(&manifest, &archive, &label, source)?; let digest = deterministic_digest(&archive.files); let identity = SourceQualifiedPluginId::new(source, manifest.id.clone()); - - Ok(DiscoveredPluginPackage { + let package = DiscoveredPluginPackage { identity, package_path: path.to_path_buf(), - package_label: label.to_string(), + package_label: label, digest, manifest, entries: archive.files.keys().cloned().collect(), - }) + }; + Ok(MaterializedPluginPackage { package, files }) +} + +fn collect_directory_files( + root: &Path, + dir: &Path, + label: &str, + source: PluginSourceKind, + limits: &PluginDiscoveryLimits, + files: &mut BTreeMap>, +) -> Result<(), PluginDiagnostic> { + let mut entries = fs::read_dir(dir) + .map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "plugin directory could not be listed: {}", + safe_io_error(&error) + ), + ) + .with_source(source) + .with_package(label.to_string()) + })? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .collect::>(); + entries.sort(); + + for path in entries { + let metadata = fs::symlink_metadata(&path).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "plugin directory entry metadata could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(source) + .with_package(label.to_string()) + })?; + if metadata.file_type().is_symlink() { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Traversal, + PluginDiagnosticPhase::Discovery, + "plugin directory contains a symlink entry", + ) + .with_source(source) + .with_package(label.to_string())); + } + if metadata.is_dir() { + collect_directory_files(root, &path, label, source, limits, files)?; + continue; + } + if !metadata.is_file() { + continue; + } + if metadata.len() > limits.max_file_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Discovery, + "plugin directory file exceeds the configured per-file bound", + ) + .with_source(source) + .with_package(label.to_string())); + } + let canonical = fs::canonicalize(&path).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "plugin directory file could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(source) + .with_package(label.to_string()) + })?; + if !canonical.starts_with(root) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Traversal, + PluginDiagnosticPhase::Discovery, + "plugin directory file escapes the package root", + ) + .with_source(source) + .with_package(label.to_string())); + } + let relative = canonical.strip_prefix(root).map_err(|_| { + PluginDiagnostic::new( + PluginDiagnosticKind::Traversal, + PluginDiagnosticPhase::Discovery, + "plugin directory file escapes the package root", + ) + .with_source(source) + .with_package(label.to_string()) + })?; + let normalized = relative + .components() + .map(|component| component.as_os_str().to_str()) + .collect::>>() + .and_then(|parts| normalize_archive_path(&parts.join("/"))) + .ok_or_else(|| { + PluginDiagnostic::new( + PluginDiagnosticKind::Traversal, + PluginDiagnosticPhase::Discovery, + "plugin directory contains an unsafe relative path", + ) + .with_source(source) + .with_package(label.to_string()) + })?; + let content = fs::read(&path).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "plugin directory file could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(source) + .with_package(label.to_string()) + })?; + files.insert(normalized, content); + if files.len() > limits.max_entries_per_package { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Discovery, + "plugin directory contains more files than the configured bound", + ) + .with_source(source) + .with_package(label.to_string())); + } + let expanded_size = files + .values() + .map(|content| content.len() as u64) + .sum::(); + if expanded_size > limits.max_expanded_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Discovery, + "plugin directory expanded size exceeds the configured bound", + ) + .with_source(source) + .with_package(label.to_string())); + } + } + Ok(()) +} + +fn write_stored_zip_file( + output_path: &Path, + files: &BTreeMap>, + limits: &PluginDiscoveryLimits, +) -> Result<(), PluginDiagnostic> { + if files.len() > limits.max_entries_per_package { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Discovery, + "plugin package contains more entries than the configured bound", + )); + } + let mut bytes = Vec::new(); + let mut central = Vec::new(); + for (name, content) in files { + let name = normalize_archive_path(name).ok_or_else(|| { + PluginDiagnostic::new( + PluginDiagnosticKind::Traversal, + PluginDiagnosticPhase::Discovery, + "plugin package entry path escapes the archive root", + ) + })?; + if content.len() as u64 > limits.max_file_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Discovery, + "plugin package entry exceeds the configured per-file bound", + )); + } + let local_offset = bytes.len() as u32; + write_u32_vec(&mut bytes, ZIP_LOCAL_FILE); + write_u16_vec(&mut bytes, 20); + write_u16_vec(&mut bytes, 0x0800); + write_u16_vec(&mut bytes, ZIP_COMPRESSION_STORED); + write_u16_vec(&mut bytes, 0); + write_u16_vec(&mut bytes, 0); + write_u32_vec(&mut bytes, 0); + write_u32_vec(&mut bytes, content.len() as u32); + write_u32_vec(&mut bytes, content.len() as u32); + write_u16_vec(&mut bytes, name.len() as u16); + write_u16_vec(&mut bytes, 0); + bytes.extend_from_slice(name.as_bytes()); + bytes.extend_from_slice(content); + + write_u32_vec(&mut central, ZIP_CENTRAL_DIRECTORY); + write_u16_vec(&mut central, 20); + write_u16_vec(&mut central, 20); + write_u16_vec(&mut central, 0x0800); + write_u16_vec(&mut central, ZIP_COMPRESSION_STORED); + write_u16_vec(&mut central, 0); + write_u16_vec(&mut central, 0); + write_u32_vec(&mut central, 0); + write_u32_vec(&mut central, content.len() as u32); + write_u32_vec(&mut central, content.len() as u32); + write_u16_vec(&mut central, name.len() as u16); + write_u16_vec(&mut central, 0); + write_u16_vec(&mut central, 0); + write_u16_vec(&mut central, 0); + write_u16_vec(&mut central, 0); + write_u32_vec(&mut central, 0); + write_u32_vec(&mut central, local_offset); + central.extend_from_slice(name.as_bytes()); + } + let central_offset = bytes.len() as u32; + bytes.extend_from_slice(¢ral); + write_u32_vec(&mut bytes, ZIP_EOCD); + write_u16_vec(&mut bytes, 0); + write_u16_vec(&mut bytes, 0); + write_u16_vec(&mut bytes, files.len() as u16); + write_u16_vec(&mut bytes, files.len() as u16); + write_u32_vec(&mut bytes, central.len() as u32); + write_u32_vec(&mut bytes, central_offset); + write_u16_vec(&mut bytes, 0); + if bytes.len() as u64 > limits.max_package_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Discovery, + "plugin package exceeds the configured package size bound", + )); + } + if let Some(parent) = output_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "plugin package output directory could not be created: {}", + safe_io_error(&error) + ), + ) + })?; + } + fs::write(output_path, bytes).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "plugin package output could not be written: {}", + safe_io_error(&error) + ), + ) + })?; + Ok(()) +} + +fn write_u16_vec(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn write_u32_vec(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); } fn validate_manifest( diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index e6c5b400..b3efec3d 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -429,10 +429,58 @@ fn parse_args_slice(args: &[String]) -> Result { fn parse_plugin_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError( - "yoi plugin requires `list` or `show `".to_string(), + "yoi plugin requires `new`, `check`, `pack`, `list`, or `show `".to_string(), )); }; match subcommand.as_str() { + "new" => { + let (plugin_args, positional) = parse_plugin_common_args(rest)?; + match positional.as_slice() { + [template, destination] => Ok(plugin_cli::PluginCliCommand::New { + template: template.clone(), + destination: PathBuf::from(destination), + args: plugin_args, + }), + [] | [_] => Err(ParseError( + "yoi plugin new requires a template and destination".to_string(), + )), + _ => Err(ParseError( + "yoi plugin new accepts exactly a template and destination".to_string(), + )), + } + } + "check" => { + let (plugin_args, positional) = parse_plugin_common_args(rest)?; + match positional.as_slice() { + [input] => Ok(plugin_cli::PluginCliCommand::Check { + input: PathBuf::from(input), + args: plugin_args, + }), + [] => Err(ParseError( + "yoi plugin check requires a plugin directory or .yoi-plugin path".to_string(), + )), + _ => Err(ParseError( + "yoi plugin check accepts exactly one plugin directory or .yoi-plugin path" + .to_string(), + )), + } + } + "pack" => { + let (plugin_args, positional, output) = parse_plugin_pack_args(rest)?; + match positional.as_slice() { + [input] => Ok(plugin_cli::PluginCliCommand::Pack { + input: PathBuf::from(input), + output, + args: plugin_args, + }), + [] => Err(ParseError( + "yoi plugin pack requires a plugin directory".to_string(), + )), + _ => Err(ParseError( + "yoi plugin pack accepts exactly one plugin directory".to_string(), + )), + } + } "list" => { let (plugin_args, positional) = parse_plugin_common_args(rest)?; if !positional.is_empty() { @@ -513,8 +561,36 @@ fn parse_plugin_common_args( Ok((parsed, positional)) } +fn parse_plugin_pack_args( + args: &[String], +) -> Result<(plugin_cli::PluginCliArgs, Vec, Option), ParseError> { + let mut normalized = Vec::new(); + let mut output = None; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--output" { + index += 1; + let Some(value) = args.get(index) else { + return Err(ParseError("--output requires a value".to_string())); + }; + output = Some(PathBuf::from(value)); + } else if let Some(value) = arg.strip_prefix("--output=") { + if value.is_empty() { + return Err(ParseError("--output requires a value".to_string())); + } + output = Some(PathBuf::from(value)); + } else { + normalized.push(arg.clone()); + } + index += 1; + } + let (plugin_args, positional) = parse_plugin_common_args(&normalized)?; + Ok((plugin_args, positional, output)) +} + fn plugin_usage() -> &'static str { - "usage: yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" + "usage: yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" } fn parse_panel_workspace(args: &[String]) -> Result { @@ -547,7 +623,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index 474c6a8e..89a48cd7 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -5,10 +5,13 @@ use std::fs; use std::path::{Path, PathBuf}; use manifest::plugin::{ - PluginConfig, PluginDiagnostic, PluginDiagnosticKind, PluginDiscoveryLimits, - PluginDiscoveryOptions, PluginDiscoveryReport, PluginPackageManifest, PluginPermission, - PluginResolution, PluginSourceKind, PluginSurface, ResolvedPlugin, ResolvedPluginRecord, - SourceQualifiedPluginId, discover_plugins, resolve_enabled_plugins, + MaterializedPluginPackage, PluginConfig, PluginDiagnostic, PluginDiagnosticKind, + PluginDiagnosticPhase, PluginDiscoveryLimits, PluginDiscoveryOptions, PluginDiscoveryReport, + PluginExactVersion, PluginGrantConfig, PluginPackageManifest, PluginPermission, + PluginResolution, PluginSourceKind, PluginSurface, RUST_COMPONENT_TOOL_TEMPLATE, + ResolvedPlugin, ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins, + read_plugin_directory, read_plugin_package_file, resolve_enabled_plugins, + write_plugin_package_file, }; use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths}; use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static}; @@ -35,17 +38,552 @@ pub(crate) enum PluginCliCommand { reference: String, args: PluginCliArgs, }, + New { + template: String, + destination: PathBuf, + args: PluginCliArgs, + }, + Check { + input: PathBuf, + args: PluginCliArgs, + }, + Pack { + input: PathBuf, + output: Option, + args: PluginCliArgs, + }, } pub(crate) fn run(command: PluginCliCommand) -> Result<()> { + if let PluginCliCommand::Check { input, args } = command { + let report = build_check_report(&input); + let rendered = render_check_report(&report, &args)?; + print!("{rendered}"); + if report.status != "active" { + return Err("plugin check failed; see diagnostics above".into()); + } + return Ok(()); + } let rendered = match command { PluginCliCommand::List(args) => render_list(&args)?, PluginCliCommand::Show { reference, args } => render_show(&reference, &args)?, + PluginCliCommand::New { + template, + destination, + args, + } => render_new(&template, &destination, &args)?, + PluginCliCommand::Check { .. } => unreachable!("handled above"), + PluginCliCommand::Pack { + input, + output, + args, + } => render_pack(&input, output.as_deref(), &args)?, }; print!("{rendered}"); Ok(()) } +fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result { + if template != "rust-component-tool" { + return Err(format!( + "unsupported plugin template `{template}` (supported: rust-component-tool)" + ) + .into()); + } + materialize_template(destination)?; + let report = NewReport { + command: "new", + template: "rust-component-tool", + destination: destination.display().to_string(), + files: RUST_COMPONENT_TOOL_TEMPLATE + .iter() + .map(|resource| resource.path.to_string()) + .collect(), + safety: AuthoringSafetyReport::default(), + next_steps: vec![ + "Review plugin.toml and generated Rust source.".to_string(), + "Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(), + "Run `yoi plugin check ` and then `yoi plugin pack `.".to_string(), + ], + }; + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_new_human(&report) +} + +fn materialize_template(destination: &Path) -> Result<()> { + if destination.exists() { + let metadata = fs::metadata(destination)?; + if !metadata.is_dir() { + return Err(format!( + "plugin template destination `{}` already exists and is not a directory", + destination.display() + ) + .into()); + } + if fs::read_dir(destination)?.next().is_some() { + return Err(format!( + "plugin template destination `{}` is not empty", + destination.display() + ) + .into()); + } + } else { + fs::create_dir_all(destination)?; + } + + for resource in RUST_COMPONENT_TOOL_TEMPLATE { + let relative = safe_template_relative_path(resource.path)?; + let path = destination.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, resource.contents)?; + } + Ok(()) +} + +fn safe_template_relative_path(path: &str) -> Result<&Path> { + let relative = Path::new(path); + if relative.is_absolute() + || relative + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(format!("embedded plugin template path `{path}` is unsafe").into()); + } + Ok(relative) +} + +#[cfg(test)] +fn render_check(input: &Path, args: &PluginCliArgs) -> Result { + let report = build_check_report(input); + render_check_report(&report, args) +} + +fn render_check_report(report: &CheckReport, args: &PluginCliArgs) -> Result { + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(report)?)); + } + render_check_human(report) +} + +fn render_pack(input: &Path, output: Option<&Path>, args: &PluginCliArgs) -> Result { + let limits = PluginDiscoveryLimits::default(); + let materialized = read_plugin_directory(input, PluginSourceKind::Project, &limits) + .map_err(|diagnostic| plugin_diagnostic_error("plugin pack", diagnostic))?; + let output_path = output + .map(Path::to_path_buf) + .unwrap_or_else(|| default_package_output_path(input)); + let packed = write_plugin_package_file(&materialized, &output_path, &limits) + .map_err(|diagnostic| plugin_diagnostic_error("plugin pack", diagnostic))?; + let report = PackReport { + command: "pack", + status: "packed", + input_path: input.display().to_string(), + output_path: packed.output_path.display().to_string(), + package: PackageReport::from_materialized(&MaterializedPluginPackage { + package: packed.package, + files: materialized.files, + }), + safety: AuthoringSafetyReport::default(), + }; + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_pack_human(&report) +} + +fn default_package_output_path(input: &Path) -> PathBuf { + let name = input + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("plugin"); + input.with_file_name(format!("{name}.yoi-plugin")) +} + +fn build_check_report(input: &Path) -> CheckReport { + let limits = PluginDiscoveryLimits::default(); + let input_kind = if input.is_dir() { + "directory" + } else { + "package" + }; + let result = if input.is_dir() { + read_plugin_directory(input, PluginSourceKind::Project, &limits) + } else { + read_plugin_package_file(input, PluginSourceKind::Project, &limits) + }; + match result { + Ok(materialized) => { + let static_inspection = inspect_materialized_package(&materialized); + let diagnostics = static_inspection_diagnostics(&static_inspection); + let status = if diagnostics.is_empty() { + "active" + } else { + "rejected" + }; + let reference = package_reference(&materialized.package.identity); + CheckReport { + command: "check", + status, + input_path: input.display().to_string(), + input_kind, + package: Some(PackageReport::from_materialized(&materialized)), + diagnostics, + static_inspection: Some(StaticInspectionReport::from_inspection( + &static_inspection, + )), + safety: AuthoringSafetyReport::default(), + next_steps: check_next_steps(status, &reference), + } + } + Err(diagnostic) => CheckReport { + command: "check", + status: "rejected", + input_path: input.display().to_string(), + input_kind, + package: None, + diagnostics: vec![PluginDiagnosticReport::from_diagnostic(&diagnostic)], + static_inspection: None, + safety: AuthoringSafetyReport::default(), + next_steps: vec![ + "Fix the reported package diagnostic and run `yoi plugin check` again.".to_string(), + ], + }, + } +} + +fn inspect_materialized_package( + materialized: &MaterializedPluginPackage, +) -> PluginStaticInspection { + let requested_permissions = materialized.package.manifest.permissions.clone(); + let record = ResolvedPluginRecord { + identity: materialized.package.identity.clone(), + source: materialized.package.identity.source, + package_path: materialized.package.package_path.clone(), + package_label: materialized.package.package_label.clone(), + digest: materialized.package.digest.clone(), + version: materialized.package.manifest.version.clone(), + manifest: materialized.package.manifest.clone(), + enabled_surfaces: materialized.package.manifest.surfaces.clone(), + grants: PluginGrantConfig { + id: Some(materialized.package.identity.to_string()), + version: Some(PluginExactVersion( + materialized.package.manifest.version.clone(), + )), + digest: Some(materialized.package.digest.clone()), + permissions: requested_permissions, + https: Vec::new(), + fs: Vec::new(), + }, + config: None, + }; + inspect_resolved_plugin_static(&record) +} + +fn static_inspection_diagnostics( + inspection: &PluginStaticInspection, +) -> Vec { + let mut diagnostics = Vec::new(); + if let Some(message) = &inspection.runtime.diagnostic { + diagnostics.push(PluginDiagnosticReport { + kind: "malformed".to_string(), + phase: "resolution".to_string(), + message: bound_text(message.clone()), + }); + } + for api in &inspection.host_apis { + if let Some(message) = &api.diagnostic { + diagnostics.push(PluginDiagnosticReport { + kind: "grant".to_string(), + phase: "resolution".to_string(), + message: bound_text(message.clone()), + }); + } + } + for tool in &inspection.tools { + if let Some(message) = &tool.diagnostic { + diagnostics.push(PluginDiagnosticReport { + kind: "malformed".to_string(), + phase: "resolution".to_string(), + message: bound_text(message.clone()), + }); + } + } + diagnostics +} + +fn check_next_steps(status: &str, reference: &str) -> Vec { + if status == "active" { + vec![ + "Package metadata is valid without executing Plugin code.".to_string(), + format!( + "To enable after review, add an explicit plugin enablement entry for `{reference}` with matching digest and grants." + ), + "Run `yoi plugin pack ` to create a deterministic .yoi-plugin archive." + .to_string(), + ] + } else { + vec!["Fix the reported diagnostics before enabling or packing this Plugin.".to_string()] + } +} + +fn plugin_diagnostic_error(context: &str, diagnostic: PluginDiagnostic) -> String { + format!("{context} failed: {}", diagnostic.message) +} + +fn render_new_human(report: &NewReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "created plugin template `{}` at {}", + report.template, report.destination + )?; + writeln!(out, "files:")?; + for file in &report.files { + writeln!(out, " - {file}")?; + } + writeln!( + out, + "safety: no network; embedded template only; no secrets generated" + )?; + writeln!(out, "next steps:")?; + for step in &report.next_steps { + writeln!(out, " - {step}")?; + } + Ok(out) +} + +fn render_check_human(report: &CheckReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "plugin check: {} [{}] input_kind={}", + report.input_path, report.status, report.input_kind + )?; + if let Some(package) = &report.package { + writeln!( + out, + "package: {} version={} digest={} entries={} source={} surfaces={} tools={}", + package.reference, + package.version, + package.digest, + package.entries.len(), + package.source, + join_or_none(&package.surfaces), + package.tools.len() + )?; + writeln!( + out, + "enablement guidance: pin reference `{}` and digest `{}` explicitly; this command does not mutate config", + package.reference, package.digest + )?; + } + if report.diagnostics.is_empty() { + writeln!(out, "diagnostics: none")?; + } else { + writeln!(out, "diagnostics:")?; + for diagnostic in &report.diagnostics { + writeln!( + out, + " - kind={} phase={} message={}", + diagnostic.kind, diagnostic.phase, diagnostic.message + )?; + } + } + writeln!( + out, + "safety: no Plugin execution; no enablement config mutation; no secrets generated" + )?; + writeln!(out, "next steps:")?; + for step in &report.next_steps { + writeln!(out, " - {step}")?; + } + Ok(out) +} + +fn render_pack_human(report: &PackReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "plugin pack: {} [{}]", + report.output_path, report.status + )?; + writeln!( + out, + "package: {} version={} digest={} entries={}", + report.package.reference, + report.package.version, + report.package.digest, + report.package.entries.len() + )?; + writeln!( + out, + "safety: deterministic stored .yoi-plugin archive; no Plugin execution; no config mutation" + )?; + Ok(out) +} + +#[derive(Serialize)] +struct AuthoringSafetyReport { + no_network: bool, + no_plugin_execution: bool, + no_enablement_config_mutation: bool, + no_secrets_generated: bool, +} + +impl Default for AuthoringSafetyReport { + fn default() -> Self { + Self { + no_network: true, + no_plugin_execution: true, + no_enablement_config_mutation: true, + no_secrets_generated: true, + } + } +} + +#[derive(Serialize)] +struct NewReport { + command: &'static str, + template: &'static str, + destination: String, + files: Vec, + safety: AuthoringSafetyReport, + next_steps: Vec, +} + +#[derive(Serialize)] +struct CheckReport { + command: &'static str, + status: &'static str, + input_path: String, + input_kind: &'static str, + package: Option, + diagnostics: Vec, + static_inspection: Option, + safety: AuthoringSafetyReport, + next_steps: Vec, +} + +#[derive(Serialize)] +struct PackReport { + command: &'static str, + status: &'static str, + input_path: String, + output_path: String, + package: PackageReport, + safety: AuthoringSafetyReport, +} + +#[derive(Serialize)] +struct PackageReport { + reference: String, + package: String, + source: String, + version: String, + schema_version: u32, + digest: String, + package_path: String, + entries: Vec, + surfaces: Vec, + tools: Vec, + permissions: Vec, +} + +impl PackageReport { + fn from_materialized(materialized: &MaterializedPluginPackage) -> Self { + Self { + reference: package_reference(&materialized.package.identity), + package: materialized.package.manifest.id.clone(), + source: materialized.package.identity.source.to_string(), + version: materialized.package.manifest.version.clone(), + schema_version: materialized.package.manifest.schema_version, + digest: materialized.package.digest.clone(), + package_path: materialized.package.package_path.display().to_string(), + entries: materialized.package.entries.iter().cloned().collect(), + surfaces: materialized + .package + .manifest + .surfaces + .iter() + .map(ToString::to_string) + .collect(), + tools: materialized + .package + .manifest + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect(), + permissions: materialized + .package + .manifest + .permissions + .iter() + .map(|permission| permission_name(permission.clone()).to_string()) + .collect(), + } + } +} + +#[derive(Serialize)] +struct PluginDiagnosticReport { + kind: String, + phase: String, + message: String, +} + +impl PluginDiagnosticReport { + fn from_diagnostic(diagnostic: &PluginDiagnostic) -> Self { + Self { + kind: diagnostic_kind(&diagnostic.kind).to_string(), + phase: diagnostic_phase(&diagnostic.phase).to_string(), + message: bound_text(diagnostic.message.clone()), + } + } +} + +fn diagnostic_phase(phase: &PluginDiagnosticPhase) -> &'static str { + match phase { + PluginDiagnosticPhase::Discovery => "discovery", + PluginDiagnosticPhase::Manifest => "manifest", + PluginDiagnosticPhase::Resolution => "resolution", + } +} + +fn package_reference(identity: &SourceQualifiedPluginId) -> String { + identity.to_string() +} + +fn permission_name(permission: PluginPermission) -> String { + permission.label() +} + +#[derive(Serialize)] +struct StaticInspectionReport { + status: String, + diagnostics: usize, +} + +impl StaticInspectionReport { + fn from_inspection(inspection: &PluginStaticInspection) -> Self { + let diagnostics = static_inspection_diagnostics(inspection).len(); + let status = if diagnostics == 0 { + "active" + } else { + "rejected" + }; + Self { + status: status.to_string(), + diagnostics, + } + } +} + fn render_list(args: &PluginCliArgs) -> Result { let snapshot = build_snapshot(args)?; if args.json { @@ -1254,6 +1792,188 @@ mod tests { assert!(show_output.contains("eligible=false")); } + #[test] + fn plugin_new_creates_template_files_and_refuses_non_empty_destination() { + let dir = tempdir().unwrap(); + let destination = dir.path().join("my-plugin"); + + let output = render_new( + "rust-component-tool", + &destination, + &PluginCliArgs::default(), + ) + .unwrap(); + + assert!(output.contains("created plugin template")); + for resource in RUST_COMPONENT_TOOL_TEMPLATE { + assert!( + destination.join(resource.path).is_file(), + "missing {}", + resource.path + ); + } + let check_json = render_check( + &destination, + &PluginCliArgs { + json: true, + ..PluginCliArgs::default() + }, + ) + .unwrap(); + let check_value: serde_json::Value = serde_json::from_str(&check_json).unwrap(); + assert_eq!(check_value["status"], "active"); + let error = render_new( + "rust-component-tool", + &destination, + &PluginCliArgs::default(), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("not empty")); + } + + #[test] + fn plugin_check_accepts_valid_directory_and_reports_json_shape() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + fs::create_dir_all(&plugin).unwrap(); + fs::write( + plugin.join("plugin.toml"), + plugin_manifest("echo", "echo", "object", &["echo"]), + ) + .unwrap(); + fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap(); + + let human = render_check(&plugin, &PluginCliArgs::default()).unwrap(); + assert!(human.contains("[active]")); + assert!(human.contains("digest=")); + assert!(human.contains("does not mutate config")); + + let json = render_check( + &plugin, + &PluginCliArgs { + json: true, + ..PluginCliArgs::default() + }, + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["command"], "check"); + assert_eq!(value["status"], "active"); + assert_eq!(value["input_kind"], "directory"); + assert_eq!(value["package"]["reference"], "project:echo"); + assert_eq!(value["safety"]["no_plugin_execution"], true); + } + + #[test] + fn plugin_check_rejects_invalid_manifest_and_missing_runtime_artifact() { + let dir = tempdir().unwrap(); + let invalid = dir.path().join("invalid"); + fs::create_dir_all(&invalid).unwrap(); + fs::write( + invalid.join("plugin.toml"), + "schema_version = 1\nid = [\"bad\"]\n", + ) + .unwrap(); + + let invalid_json = render_check( + &invalid, + &PluginCliArgs { + json: true, + ..PluginCliArgs::default() + }, + ) + .unwrap(); + let invalid_value: serde_json::Value = serde_json::from_str(&invalid_json).unwrap(); + assert_eq!(invalid_value["status"], "rejected"); + assert_eq!(invalid_value["diagnostics"][0]["phase"], "manifest"); + + let missing = dir.path().join("missing-runtime"); + fs::create_dir_all(&missing).unwrap(); + fs::write( + missing.join("plugin.toml"), + plugin_manifest_missing_runtime_entry("missing_runtime"), + ) + .unwrap(); + let missing_output = render_check(&missing, &PluginCliArgs::default()).unwrap(); + assert!(missing_output.contains("rejected")); + assert!(missing_output.contains("path not present")); + } + + #[test] + fn plugin_check_rejects_unsafe_package_archive() { + let dir = tempdir().unwrap(); + let package = dir.path().join("unsafe.yoi-plugin"); + write_stored_zip( + &package, + &[ + ( + "plugin.toml", + plugin_manifest("unsafe", "Echo", "object", &["Echo"]).as_bytes(), + ), + ("../escape.wasm", b"not wasm"), + ], + ); + + let output = render_check(&package, &PluginCliArgs::default()).unwrap(); + assert!(output.contains("rejected")); + assert!(output.contains("escapes")); + } + + #[test] + fn plugin_pack_is_deterministic_and_discoverable() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + fs::create_dir_all(&plugin).unwrap(); + fs::write( + plugin.join("plugin.toml"), + plugin_manifest("echo", "echo", "object", &["echo"]), + ) + .unwrap(); + fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap(); + let first = dir.path().join("first.yoi-plugin"); + let second = dir.path().join("second.yoi-plugin"); + + let first_json = render_pack( + &plugin, + Some(&first), + &PluginCliArgs { + json: true, + ..PluginCliArgs::default() + }, + ) + .unwrap(); + let second_json = render_pack( + &plugin, + Some(&second), + &PluginCliArgs { + json: true, + ..PluginCliArgs::default() + }, + ) + .unwrap(); + assert_eq!(fs::read(&first).unwrap(), fs::read(&second).unwrap()); + let first_value: serde_json::Value = serde_json::from_str(&first_json).unwrap(); + let second_value: serde_json::Value = serde_json::from_str(&second_json).unwrap(); + assert_eq!(first_value["command"], "pack"); + assert_eq!(first_value["status"], "packed"); + assert_eq!( + first_value["package"]["digest"], + second_value["package"]["digest"] + ); + + let workspace = dir.path().join("workspace"); + fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap(); + fs::copy(&first, workspace.join(".yoi/plugins/echo.yoi-plugin")).unwrap(); + let discovery = discover_plugins(&PluginDiscoveryOptions { + workspace_root: workspace, + user_data_home: None, + limits: PluginDiscoveryLimits::default(), + }); + assert_eq!(discovery.packages.len(), 1); + assert_eq!(discovery.packages[0].identity.to_string(), "project:echo"); + } + #[test] fn ambiguous_ref_is_bounded_error() { let snapshot = PluginInspectionSnapshot { diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index 25edef62..930e6fbb 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -23,11 +23,11 @@ Implemented foundation: - first-party Rust PDK helpers for Component Model Tool guests; - embedded Rust Component Tool starter template; - `https` and `fs` host APIs for Tool runtime; -- read-only `yoi plugin list/show` inspection. +- read-only `yoi plugin list/show` inspection; +- local first-party authoring commands: `yoi plugin new`, `yoi plugin check`, and `yoi plugin pack`. Still intentionally separate/future work: -- `yoi plugin new/check/pack` authoring commands; - multi-language SDK/PDK crates; - Service / Ingress surfaces; - WebSocket or inbound HTTP for bidirectional bridges; @@ -54,6 +54,37 @@ A `.yoi-plugin` package is currently a bounded ZIP archive. For now, create it w The archive root must contain `plugin.toml`. Runtime files referenced by the manifest must also be inside the archive. Yoi rejects path traversal, root escapes, malformed manifests, unsupported API/runtime versions, and other unsafe archive shapes. +## Authoring CLI + +Use the local authoring commands for first-party deterministic authoring. These commands never fetch remote templates, never run Plugin code, never mutate enablement configuration, and never generate or embed secrets. + +Create a Rust Component Tool starter from embedded resources: + +```bash +yoi plugin new rust-component-tool ./my-plugin +``` + +`new` writes only inside the requested destination and refuses an existing non-empty destination. The generated template includes `plugin.toml`, Rust source, Cargo metadata, README next steps, and a placeholder `plugin.component.wasm` artifact so local `check`/`pack` validation can run immediately. Replace the placeholder with a real built component before enabling or executing the Plugin. + +Validate a source directory or an existing `.yoi-plugin` archive: + +```bash +yoi plugin check ./my-plugin +yoi plugin check ./my-plugin --json +yoi plugin check ./my-plugin.yoi-plugin --json +``` + +`check` performs bounded static validation of the directory/archive shape, manifest, runtime declaration, referenced artifact presence, Tool schemas, permission declarations, host API declarations, archive safety, and deterministic digest when a package can be materialized. Component-world validation is metadata-only: it verifies the declared world string and runtime manifest shape, but it does not instantiate or execute the component. Invalid checks print the same structured report and exit non-zero. + +Pack a source directory into a deterministic stored `.yoi-plugin` archive: + +```bash +yoi plugin pack ./my-plugin +yoi plugin pack ./my-plugin --output ./my-plugin.yoi-plugin --json +``` + +`pack` rejects malformed manifests, missing runtime artifacts, symlinks/root escapes, and unsupported package shapes. The JSON output contains the stable package reference, output path, digest, entries, and safety flags. After review, copy the package to `.yoi/plugins/` (or the user Plugin store) and add explicit Profile/config enablement with pinned digest and grants; packing and checking do not do this for you. + ## Manifest: `plugin.toml` A minimal Component Model Tool Plugin manifest looks like this: diff --git a/resources/plugin/templates/rust-component-tool/README.md b/resources/plugin/templates/rust-component-tool/README.md index 9208c75f..0fecba15 100644 --- a/resources/plugin/templates/rust-component-tool/README.md +++ b/resources/plugin/templates/rust-component-tool/README.md @@ -26,14 +26,12 @@ If this template is copied elsewhere before crates.io publication exists, pin a yoi-plugin-pdk = { git = "https://github.com/example/yoi.git", package = "yoi-plugin-pdk", rev = "" } ``` -Crates.io publication, remote template fetching, and `yoi plugin new/check/pack` are intentionally deferred to later authoring-tooling work. +`plugin.component.wasm` in the template is a text placeholder so `yoi plugin check` and `yoi plugin pack` can exercise deterministic local package validation immediately. Replace it with a real built component before enabling or executing the Plugin. ## Next steps 1. Replace package/plugin ids, names, descriptions, and Tool schema. 2. Replace `EchoInput` / `EchoOutput` and `handle_echo` with your Tool logic. -3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment. -4. Package `plugin.toml` and `plugin.component.wasm` into a `.yoi-plugin` archive. -5. Use `yoi plugin list` / `yoi plugin show` plus focused runtime tests to inspect and validate the package. - -The exact component build/pack command is not part of this template yet because deterministic `yoi plugin new/check/pack` authoring commands are a separate planned Ticket. +3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment, replacing the placeholder `plugin.component.wasm`. +4. Run `yoi plugin check .` and `yoi plugin pack . --output ./my-plugin.yoi-plugin`. +5. Copy the package to a Plugin store and add explicit enablement with pinned digest/grants after review. diff --git a/resources/plugin/templates/rust-component-tool/plugin.component.wasm b/resources/plugin/templates/rust-component-tool/plugin.component.wasm new file mode 100644 index 00000000..6193fb38 --- /dev/null +++ b/resources/plugin/templates/rust-component-tool/plugin.component.wasm @@ -0,0 +1 @@ +placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling.