merge: plugin authoring cli
This commit is contained in:
commit
0430ed982d
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
#[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<DiscoveredPluginPackage>,
|
||||
|
|
@ -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<MaterializedPluginPackage, PluginDiagnostic> {
|
||||
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<MaterializedPluginPackage, PluginDiagnostic> {
|
||||
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<PackedPluginPackage, PluginDiagnostic> {
|
||||
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<MaterializedPluginPackage, PluginDiagnostic> {
|
||||
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<String, Vec<u8>>,
|
||||
limits: &PluginDiscoveryLimits,
|
||||
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
|
||||
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<String, Vec<u8>>,
|
||||
) -> 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::<Vec<_>>();
|
||||
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::<Option<Vec<_>>>()
|
||||
.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::<u64>();
|
||||
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<String, Vec<u8>>,
|
||||
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<u8>, value: u16) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_u32_vec(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn validate_manifest(
|
||||
|
|
|
|||
|
|
@ -429,10 +429,58 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
|||
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
||||
let Some((subcommand, rest)) = args.split_first() else {
|
||||
return Err(ParseError(
|
||||
"yoi plugin requires `list` or `show <ref>`".to_string(),
|
||||
"yoi plugin requires `new`, `check`, `pack`, `list`, or `show <ref>`".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<String>, Option<PathBuf>), 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 <ref> [--workspace PATH] [--profile REF] [--json]"
|
||||
"usage: yoi plugin new rust-component-tool <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]"
|
||||
}
|
||||
|
||||
fn parse_panel_workspace(args: &[String]) -> Result<PathBuf, ParseError> {
|
||||
|
|
@ -547,7 +623,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
|||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@ use std::collections::BTreeMap;
|
|||
use std::error::Error;
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
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 +39,607 @@ 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<PathBuf>,
|
||||
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 == "rejected" {
|
||||
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<String> {
|
||||
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 <path>` and then `yoi plugin pack <path>`.".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<()> {
|
||||
match fs::symlink_metadata(destination) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(format!(
|
||||
"plugin template destination `{}` is a symlink; refusing to follow it",
|
||||
destination.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
fs::create_dir_all(destination)?;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
|
||||
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<String> {
|
||||
let report = build_check_report(input);
|
||||
render_check_report(&report, args)
|
||||
}
|
||||
|
||||
fn render_check_report(report: &CheckReport, args: &PluginCliArgs) -> Result<String> {
|
||||
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<String> {
|
||||
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 static_diagnostics = static_inspection_diagnostics(&static_inspection);
|
||||
let placeholder_diagnostic = placeholder_component_diagnostic(&materialized);
|
||||
let status = if !static_diagnostics.is_empty() {
|
||||
"rejected"
|
||||
} else if placeholder_diagnostic.is_some() {
|
||||
"partial"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let mut diagnostics = static_diagnostics;
|
||||
diagnostics.extend(placeholder_diagnostic);
|
||||
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<PluginDiagnosticReport> {
|
||||
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 placeholder_component_diagnostic(
|
||||
materialized: &MaterializedPluginPackage,
|
||||
) -> Option<PluginDiagnosticReport> {
|
||||
let runtime = materialized.package.manifest.runtime.as_ref()?;
|
||||
let component = runtime.component.as_deref()?;
|
||||
let component_bytes = materialized.files.get(component)?;
|
||||
let placeholder_bytes = RUST_COMPONENT_TOOL_TEMPLATE
|
||||
.iter()
|
||||
.find(|resource| resource.path == "plugin.component.wasm")?
|
||||
.contents
|
||||
.as_bytes();
|
||||
if component_bytes != placeholder_bytes {
|
||||
return None;
|
||||
}
|
||||
Some(PluginDiagnosticReport {
|
||||
kind: "placeholder".to_string(),
|
||||
phase: "runtime".to_string(),
|
||||
message: format!(
|
||||
"plugin component runtime artifact `{component}` is the generated placeholder; replace it with a real built component before enabling or execution"
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn check_next_steps(status: &str, reference: &str) -> Vec<String> {
|
||||
match 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 <path>` to create a deterministic .yoi-plugin archive."
|
||||
.to_string(),
|
||||
],
|
||||
"partial" => vec![
|
||||
"Replace the generated placeholder component artifact with a real built component."
|
||||
.to_string(),
|
||||
"Run `yoi plugin check <path>` again before enabling or execution.".to_string(),
|
||||
"Do not enable this Plugin while check status is partial.".to_string(),
|
||||
],
|
||||
_ => {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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()
|
||||
)?;
|
||||
match report.status {
|
||||
"active" => writeln!(
|
||||
out,
|
||||
"enablement guidance: pin reference `{}` and digest `{}` explicitly; this command does not mutate config",
|
||||
package.reference, package.digest
|
||||
)?,
|
||||
"partial" => writeln!(
|
||||
out,
|
||||
"enablement guidance: not ready to enable; replace the generated placeholder component and rerun check; this command does not mutate config"
|
||||
)?,
|
||||
_ => writeln!(
|
||||
out,
|
||||
"enablement guidance: not ready to enable; fix diagnostics first; this command does not mutate config"
|
||||
)?,
|
||||
}
|
||||
}
|
||||
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<String> {
|
||||
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<String>,
|
||||
safety: AuthoringSafetyReport,
|
||||
next_steps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CheckReport {
|
||||
command: &'static str,
|
||||
status: &'static str,
|
||||
input_path: String,
|
||||
input_kind: &'static str,
|
||||
package: Option<PackageReport>,
|
||||
diagnostics: Vec<PluginDiagnosticReport>,
|
||||
static_inspection: Option<StaticInspectionReport>,
|
||||
safety: AuthoringSafetyReport,
|
||||
next_steps: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
surfaces: Vec<String>,
|
||||
tools: Vec<String>,
|
||||
permissions: Vec<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
let snapshot = build_snapshot(args)?;
|
||||
if args.json {
|
||||
|
|
@ -1254,6 +1848,222 @@ 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"], "partial");
|
||||
assert_eq!(check_value["diagnostics"][0]["kind"], "placeholder");
|
||||
assert!(
|
||||
check_value["diagnostics"][0]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("generated placeholder")
|
||||
);
|
||||
assert!(
|
||||
check_value["next_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|step| step.as_str().unwrap_or_default().contains("Do not enable"))
|
||||
);
|
||||
let human_check = render_check(&destination, &PluginCliArgs::default()).unwrap();
|
||||
assert!(human_check.contains("[partial]"));
|
||||
assert!(human_check.contains("not ready to enable"));
|
||||
let error = render_new(
|
||||
"rust-component-tool",
|
||||
&destination,
|
||||
&PluginCliArgs::default(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("not empty"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn plugin_new_refuses_symlink_destination_without_following_it() {
|
||||
let dir = tempdir().unwrap();
|
||||
let target = dir.path().join("target");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
let link = dir.path().join("linkdest");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
let error = render_new("rust-component-tool", &link, &PluginCliArgs::default())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("symlink"));
|
||||
assert!(!target.join("plugin.toml").exists());
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
|
|
|||
|
|
@ -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 or destination symlink. 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. A generated placeholder component produces `status = "partial"` plus a diagnostic and is not enablement-ready until replaced. 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:
|
||||
|
|
|
|||
|
|
@ -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 = "<pinned-yoi-revision>" }
|
||||
```
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling.
|
||||
Loading…
Reference in New Issue
Block a user