use std::collections::BTreeMap;
use std::path::{Component, Path};
use super::manifest::PluginManifest;
pub const MAX_PLUGIN_FILES: usize = 256;
pub const MAX_PLUGIN_FILE_BYTES: usize = 64 * 1024;
pub const MAX_PLUGIN_TOTAL_BYTES: usize = 4 * 1024 * 1024;
const MANIFEST_PATHS: &[&str] = &[
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".cursor-plugin/plugin.json",
];
#[derive(Debug, Clone)]
pub struct PluginFileSet {
pub files: BTreeMap<String, Vec<u8>>,
pub dir_name: String,
}
impl PluginFileSet {
pub fn from_map(
dir_name: impl Into<String>,
files: BTreeMap<String, Vec<u8>>,
) -> Result<Self, String> {
let mut total_bytes: usize = 0;
if files.len() > MAX_PLUGIN_FILES {
return Err(format!(
"plugin contains {} files, exceeding the {MAX_PLUGIN_FILES}-file limit",
files.len()
));
}
for (path, bytes) in &files {
if path.starts_with('/') {
return Err(format!("plugin file path '{path}' must be relative"));
}
for component in std::path::Path::new(path).components() {
if component == Component::ParentDir {
return Err(format!(
"path traversal detected in plugin file map: '{path}'"
));
}
}
let file_size = bytes.len();
if file_size > MAX_PLUGIN_FILE_BYTES {
return Err(format!(
"plugin file '{path}' is {file_size} bytes, exceeding the {MAX_PLUGIN_FILE_BYTES}-byte limit"
));
}
total_bytes += file_size;
if total_bytes > MAX_PLUGIN_TOTAL_BYTES {
return Err(format!(
"plugin total size exceeds {MAX_PLUGIN_TOTAL_BYTES} bytes"
));
}
}
Ok(Self {
files,
dir_name: dir_name.into(),
})
}
pub fn from_dir(path: &Path) -> Result<Self, String> {
let canonical_root = path.canonicalize().map_err(|e| {
format!(
"cannot canonicalize plugin directory {}: {}",
path.display(),
e
)
})?;
let dir_name = canonical_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("plugin")
.to_string();
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut total_bytes: usize = 0;
collect_dir(
&canonical_root,
&canonical_root,
&mut files,
&mut total_bytes,
)?;
Ok(Self { files, dir_name })
}
pub fn manifest(&self) -> Result<(PluginManifest, Vec<String>), String> {
for manifest_path in MANIFEST_PATHS {
if let Some(bytes) = self.files.get(*manifest_path) {
let text = std::str::from_utf8(bytes)
.map_err(|_| format!("{manifest_path} is not valid UTF-8"))?;
let manifest: PluginManifest = serde_json::from_str(text)
.map_err(|e| format!("failed to parse {manifest_path}: {e}"))?;
let mut warnings = Vec::new();
for key in manifest.extra.keys() {
warnings.push(format!(
"plugin manifest: unrecognized field '{key}' will be ignored"
));
}
return Ok((manifest, warnings));
}
}
let name = dir_name_to_plugin_name(&self.dir_name);
Ok((
PluginManifest {
name,
display_name: None,
version: None,
description: None,
author: None,
homepage: None,
repository: None,
license: None,
keywords: Vec::new(),
skills: None,
commands: None,
agents: None,
mcp_servers: None,
extra: Default::default(),
},
vec!["no plugin.json manifest found; name derived from directory name".to_string()],
))
}
pub fn text_file(&self, path: &str) -> Option<String> {
let bytes = self.files.get(path)?;
String::from_utf8(bytes.clone()).ok()
}
pub fn list_dir<'a>(&'a self, dir_prefix: &str) -> Vec<(&'a str, &'a str)> {
let prefix = if dir_prefix.ends_with('/') {
dir_prefix.to_string()
} else {
format!("{dir_prefix}/")
};
self.files
.keys()
.filter_map(|k| {
let rest = k.strip_prefix(&prefix)?;
if rest.is_empty() || rest.contains('/') {
None
} else {
Some((rest, k.as_str()))
}
})
.collect()
}
pub fn list_dir_recursive<'a>(&'a self, dir_prefix: &str) -> Vec<&'a str> {
let prefix = if dir_prefix.ends_with('/') {
dir_prefix.to_string()
} else {
format!("{dir_prefix}/")
};
self.files
.keys()
.filter(|k| k.starts_with(&prefix))
.map(|k| k.as_str())
.collect()
}
}
fn dir_name_to_plugin_name(name: &str) -> String {
let lower = name.to_lowercase();
let result: String = lower
.chars()
.map(|c| {
if c.is_ascii_lowercase() || c.is_ascii_digit() {
c
} else {
'-'
}
})
.collect();
let mut out = String::new();
let mut prev_was_hyphen = true; for ch in result.chars() {
if ch == '-' {
if !prev_was_hyphen {
out.push(ch);
}
prev_was_hyphen = true;
} else {
out.push(ch);
prev_was_hyphen = false;
}
}
let out = out.trim_end_matches('-');
if out.is_empty() {
"plugin".to_string()
} else {
out.to_string()
}
}
fn collect_dir(
root: &Path,
current: &Path,
files: &mut BTreeMap<String, Vec<u8>>,
total_bytes: &mut usize,
) -> Result<(), String> {
let entries = std::fs::read_dir(current)
.map_err(|e| format!("cannot read directory {}: {}", current.display(), e))?;
for entry_result in entries {
let entry = entry_result.map_err(|e| {
format!(
"error reading directory entry in {}: {}",
current.display(),
e
)
})?;
let entry_path = entry.path();
let metadata = entry_path
.symlink_metadata()
.map_err(|e| format!("cannot stat {}: {}", entry_path.display(), e))?;
if metadata.file_type().is_symlink() {
return Err(format!(
"symlink {} is not allowed in a plugin directory",
entry_path.display()
));
}
let rel = entry_path.strip_prefix(root).map_err(|_| {
format!(
"path {} is not under root {}",
entry_path.display(),
root.display()
)
})?;
for component in rel.components() {
if component == Component::ParentDir {
return Err(format!(
"path traversal detected in plugin directory: {}",
entry_path.display()
));
}
}
let rel_str = rel.to_string_lossy().replace('\\', "/");
if metadata.is_dir() {
collect_dir(root, &entry_path, files, total_bytes)?;
} else {
let file_size = metadata.len() as usize;
if file_size > MAX_PLUGIN_FILE_BYTES {
return Err(format!(
"plugin file '{rel_str}' is {file_size} bytes, exceeding the {MAX_PLUGIN_FILE_BYTES}-byte limit"
));
}
*total_bytes += file_size;
if *total_bytes > MAX_PLUGIN_TOTAL_BYTES {
return Err(format!(
"plugin directory total size exceeds {MAX_PLUGIN_TOTAL_BYTES} bytes"
));
}
if files.len() >= MAX_PLUGIN_FILES {
return Err(format!(
"plugin directory contains more than {MAX_PLUGIN_FILES} files"
));
}
let content = std::fs::read(&entry_path)
.map_err(|e| format!("cannot read {}: {}", entry_path.display(), e))?;
files.insert(rel_str, content);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dir_name_to_plugin_name_simple() {
assert_eq!(dir_name_to_plugin_name("microsoft-docs"), "microsoft-docs");
assert_eq!(dir_name_to_plugin_name("MyPlugin"), "myplugin");
assert_eq!(dir_name_to_plugin_name("my_plugin"), "my-plugin");
assert_eq!(dir_name_to_plugin_name("---test---"), "test");
assert_eq!(dir_name_to_plugin_name("my plugin"), "my-plugin");
}
#[test]
fn plugin_file_set_from_fixture() {
let fixture = std::path::Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../testdata/plugins/microsoft-docs"
));
let fs = PluginFileSet::from_dir(fixture).expect("should load microsoft-docs fixture");
assert!(fs.files.contains_key(".claude-plugin/plugin.json"));
assert!(fs.files.contains_key(".mcp.json"));
assert!(fs.files.contains_key("agents/docs-researcher.md"));
assert!(fs.files.contains_key("skills/microsoft-docs/SKILL.md"));
assert!(fs.files.contains_key("commands/ms-docs.md"));
}
#[test]
fn manifest_discovery_from_fixture() {
let fixture = std::path::Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../testdata/plugins/microsoft-docs"
));
let fs = PluginFileSet::from_dir(fixture).unwrap();
let (manifest, warnings) = fs.manifest().unwrap();
assert_eq!(manifest.name, "microsoft-docs");
assert!(
warnings.iter().any(|w| w.contains("interface")),
"expected warning about 'interface' field, got: {warnings:?}"
);
}
#[test]
fn synthesized_manifest_for_no_manifest_dir() {
let tmpdir = tempfile::tempdir().unwrap();
std::fs::write(tmpdir.path().join("hello.md"), b"# Hello").unwrap();
let plugin_dir = tmpdir.path().join("my-test-plugin");
std::fs::create_dir(&plugin_dir).unwrap();
std::fs::write(plugin_dir.join("README.md"), b"content").unwrap();
let fs = PluginFileSet::from_dir(&plugin_dir).unwrap();
let (manifest, warnings) = fs.manifest().unwrap();
assert_eq!(manifest.name, "my-test-plugin");
assert!(warnings.iter().any(|w| w.contains("no plugin.json")));
}
#[cfg(unix)]
#[test]
fn symlink_rejected_even_within_root() {
let tmpdir = tempfile::tempdir().unwrap();
let plugin_dir = tmpdir.path().join("my-plugin");
std::fs::create_dir(&plugin_dir).unwrap();
std::fs::write(plugin_dir.join("README.md"), b"content").unwrap();
std::os::unix::fs::symlink(plugin_dir.join("README.md"), plugin_dir.join("link.md"))
.unwrap();
let err = PluginFileSet::from_dir(&plugin_dir).unwrap_err();
assert!(
err.contains("symlink"),
"expected symlink error, got: {err}"
);
}
#[cfg(unix)]
#[test]
fn symlink_directory_cycle_rejected() {
let tmpdir = tempfile::tempdir().unwrap();
let plugin_dir = tmpdir.path().join("my-plugin");
std::fs::create_dir(&plugin_dir).unwrap();
std::fs::write(plugin_dir.join("README.md"), b"content").unwrap();
std::os::unix::fs::symlink(&plugin_dir, plugin_dir.join("loop")).unwrap();
let err = PluginFileSet::from_dir(&plugin_dir).unwrap_err();
assert!(
err.contains("symlink"),
"expected symlink error, got: {err}"
);
}
#[test]
fn oversized_file_rejected() {
let tmpdir = tempfile::tempdir().unwrap();
let big = vec![b'x'; MAX_PLUGIN_FILE_BYTES + 1];
std::fs::write(tmpdir.path().join("big.txt"), &big).unwrap();
let err = PluginFileSet::from_dir(tmpdir.path()).unwrap_err();
assert!(err.contains("exceeding the"), "error was: {err}");
}
}