1use std::collections::BTreeMap;
8use std::path::{Component, Path};
9
10use super::manifest::{
11 AGENT_PLUGINS_V1_MANIFEST_SCHEMA, PluginManifest, parse_agent_plugins_v1_manifest,
12};
13
14pub const MAX_PLUGIN_FILES: usize = 256;
18pub const MAX_PLUGIN_FILE_BYTES: usize = 128 * 1024;
20pub const MAX_PLUGIN_TOTAL_BYTES: usize = 4 * 1024 * 1024; const MANIFEST_PATHS: &[&str] = &[
25 ".claude-plugin/plugin.json",
26 ".codex-plugin/plugin.json",
27 ".cursor-plugin/plugin.json",
28];
29
30#[derive(Debug, Clone)]
36pub struct PluginFileSet {
37 pub files: BTreeMap<String, Vec<u8>>,
39 pub dir_name: String,
41}
42
43impl PluginFileSet {
44 pub fn from_map(
50 dir_name: impl Into<String>,
51 files: BTreeMap<String, Vec<u8>>,
52 ) -> Result<Self, String> {
53 let mut total_bytes: usize = 0;
54 if files.len() > MAX_PLUGIN_FILES {
55 return Err(format!(
56 "plugin contains {} files, exceeding the {MAX_PLUGIN_FILES}-file limit",
57 files.len()
58 ));
59 }
60 for (path, bytes) in &files {
61 if path.starts_with('/') {
63 return Err(format!("plugin file path '{path}' must be relative"));
64 }
65 for component in std::path::Path::new(path).components() {
66 if component == Component::ParentDir {
67 return Err(format!(
68 "path traversal detected in plugin file map: '{path}'"
69 ));
70 }
71 }
72 let file_size = bytes.len();
73 if file_size > MAX_PLUGIN_FILE_BYTES {
74 return Err(format!(
75 "plugin file '{path}' is {file_size} bytes, exceeding the {MAX_PLUGIN_FILE_BYTES}-byte limit"
76 ));
77 }
78 total_bytes += file_size;
79 if total_bytes > MAX_PLUGIN_TOTAL_BYTES {
80 return Err(format!(
81 "plugin total size exceeds {MAX_PLUGIN_TOTAL_BYTES} bytes"
82 ));
83 }
84 }
85 Ok(Self {
86 files,
87 dir_name: dir_name.into(),
88 })
89 }
90
91 pub fn from_dir(path: &Path) -> Result<Self, String> {
98 let canonical_root = path.canonicalize().map_err(|e| {
99 format!(
100 "cannot canonicalize plugin directory {}: {}",
101 path.display(),
102 e
103 )
104 })?;
105
106 let dir_name = canonical_root
107 .file_name()
108 .and_then(|n| n.to_str())
109 .unwrap_or("plugin")
110 .to_string();
111
112 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
113 let mut total_bytes: usize = 0;
114
115 collect_dir(
116 &canonical_root,
117 &canonical_root,
118 &mut files,
119 &mut total_bytes,
120 )?;
121
122 Ok(Self { files, dir_name })
123 }
124
125 pub fn manifest(&self) -> Result<(PluginManifest, Vec<String>), String> {
132 if let Some(bytes) = self.files.get("plugin.json") {
133 let text = std::str::from_utf8(bytes)
134 .map_err(|_| "plugin.json is not valid UTF-8".to_string())?;
135 let schema = serde_json::from_str::<serde_json::Value>(text)
136 .ok()
137 .and_then(|value| value.get("$schema")?.as_str().map(str::to_string));
138 if schema.as_deref() == Some(AGENT_PLUGINS_V1_MANIFEST_SCHEMA)
139 || schema
140 .as_deref()
141 .is_some_and(|schema| schema.starts_with("https://agent-plugins.org/schemas/"))
142 {
143 return parse_agent_plugins_v1_manifest(text);
144 }
145 if !MANIFEST_PATHS
146 .iter()
147 .any(|manifest_path| self.files.contains_key(*manifest_path))
148 {
149 return parse_agent_plugins_v1_manifest(text);
150 }
151 }
152
153 for manifest_path in MANIFEST_PATHS {
154 if let Some(bytes) = self.files.get(*manifest_path) {
155 let text = std::str::from_utf8(bytes)
156 .map_err(|_| format!("{manifest_path} is not valid UTF-8"))?;
157 let manifest: PluginManifest = serde_json::from_str(text)
158 .map_err(|e| format!("failed to parse {manifest_path}: {e}"))?;
159 let mut warnings = Vec::new();
160 for key in manifest.extra.keys() {
161 warnings.push(format!(
162 "plugin manifest: unrecognized field '{key}' will be ignored"
163 ));
164 }
165 if self.files.contains_key("plugin.json") {
166 warnings.push(
167 "root plugin.json does not declare an Agent Plugins schema and was ignored"
168 .to_string(),
169 );
170 }
171 return Ok((manifest, warnings));
172 }
173 }
174
175 let name = dir_name_to_plugin_name(&self.dir_name);
177 Ok((
178 PluginManifest {
179 schema: None,
180 name,
181 display_name: None,
182 version: None,
183 description: None,
184 author: None,
185 homepage: None,
186 repository: None,
187 license: None,
188 keywords: Vec::new(),
189 icon: None,
190 extensions: Default::default(),
191 skills: None,
192 commands: None,
193 agents: None,
194 mcp_servers: None,
195 extra: Default::default(),
196 },
197 vec!["no plugin.json manifest found; name derived from directory name".to_string()],
198 ))
199 }
200
201 pub fn text_file(&self, path: &str) -> Option<String> {
203 let bytes = self.files.get(path)?;
204 String::from_utf8(bytes.clone()).ok()
205 }
206
207 pub fn list_dir<'a>(&'a self, dir_prefix: &str) -> Vec<(&'a str, &'a str)> {
210 let prefix = if dir_prefix.ends_with('/') {
211 dir_prefix.to_string()
212 } else {
213 format!("{dir_prefix}/")
214 };
215 self.files
216 .keys()
217 .filter_map(|k| {
218 let rest = k.strip_prefix(&prefix)?;
219 if rest.is_empty() || rest.contains('/') {
220 None
221 } else {
222 Some((rest, k.as_str()))
223 }
224 })
225 .collect()
226 }
227
228 pub fn list_dir_recursive<'a>(&'a self, dir_prefix: &str) -> Vec<&'a str> {
230 let prefix = if dir_prefix.ends_with('/') {
231 dir_prefix.to_string()
232 } else {
233 format!("{dir_prefix}/")
234 };
235 self.files
236 .keys()
237 .filter(|k| k.starts_with(&prefix))
238 .map(|k| k.as_str())
239 .collect()
240 }
241}
242
243fn dir_name_to_plugin_name(name: &str) -> String {
245 let lower = name.to_lowercase();
246 let result: String = lower
248 .chars()
249 .map(|c| {
250 if c.is_ascii_lowercase() || c.is_ascii_digit() {
251 c
252 } else {
253 '-'
254 }
255 })
256 .collect();
257 let mut out = String::new();
259 let mut prev_was_hyphen = true; for ch in result.chars() {
261 if ch == '-' {
262 if !prev_was_hyphen {
263 out.push(ch);
264 }
265 prev_was_hyphen = true;
266 } else {
267 out.push(ch);
268 prev_was_hyphen = false;
269 }
270 }
271 let out = out.trim_end_matches('-');
273 if out.is_empty() {
274 "plugin".to_string()
275 } else {
276 out.to_string()
277 }
278}
279
280fn collect_dir(
282 root: &Path,
283 current: &Path,
284 files: &mut BTreeMap<String, Vec<u8>>,
285 total_bytes: &mut usize,
286) -> Result<(), String> {
287 let entries = std::fs::read_dir(current)
288 .map_err(|e| format!("cannot read directory {}: {}", current.display(), e))?;
289
290 for entry_result in entries {
291 let entry = entry_result.map_err(|e| {
292 format!(
293 "error reading directory entry in {}: {}",
294 current.display(),
295 e
296 )
297 })?;
298 let entry_path = entry.path();
299
300 let metadata = entry_path
302 .symlink_metadata()
303 .map_err(|e| format!("cannot stat {}: {}", entry_path.display(), e))?;
304 if metadata.file_type().is_symlink() {
305 return Err(format!(
310 "symlink {} is not allowed in a plugin directory",
311 entry_path.display()
312 ));
313 }
314
315 let rel = entry_path.strip_prefix(root).map_err(|_| {
317 format!(
318 "path {} is not under root {}",
319 entry_path.display(),
320 root.display()
321 )
322 })?;
323
324 for component in rel.components() {
326 if component == Component::ParentDir {
327 return Err(format!(
328 "path traversal detected in plugin directory: {}",
329 entry_path.display()
330 ));
331 }
332 }
333
334 let rel_str = rel.to_string_lossy().replace('\\', "/");
335
336 if metadata.is_dir() {
337 collect_dir(root, &entry_path, files, total_bytes)?;
338 } else {
339 let file_size = metadata.len() as usize;
341 if file_size > MAX_PLUGIN_FILE_BYTES {
342 return Err(format!(
346 "plugin file '{rel_str}' is {file_size} bytes, exceeding the {MAX_PLUGIN_FILE_BYTES}-byte limit"
347 ));
348 }
349 *total_bytes += file_size;
350 if *total_bytes > MAX_PLUGIN_TOTAL_BYTES {
351 return Err(format!(
352 "plugin directory total size exceeds {MAX_PLUGIN_TOTAL_BYTES} bytes"
353 ));
354 }
355 if files.len() >= MAX_PLUGIN_FILES {
356 return Err(format!(
357 "plugin directory contains more than {MAX_PLUGIN_FILES} files"
358 ));
359 }
360 let content = std::fs::read(&entry_path)
361 .map_err(|e| format!("cannot read {}: {}", entry_path.display(), e))?;
362 files.insert(rel_str, content);
363 }
364 }
365
366 Ok(())
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn dir_name_to_plugin_name_simple() {
375 assert_eq!(dir_name_to_plugin_name("microsoft-docs"), "microsoft-docs");
376 assert_eq!(dir_name_to_plugin_name("MyPlugin"), "myplugin");
377 assert_eq!(dir_name_to_plugin_name("my_plugin"), "my-plugin");
378 assert_eq!(dir_name_to_plugin_name("---test---"), "test");
379 assert_eq!(dir_name_to_plugin_name("my plugin"), "my-plugin");
380 }
381
382 #[test]
383 fn plugin_file_set_from_fixture() {
384 let fixture = std::path::Path::new(concat!(
385 env!("CARGO_MANIFEST_DIR"),
386 "/../../testdata/plugins/microsoft-docs"
387 ));
388 let fs = PluginFileSet::from_dir(fixture).expect("should load microsoft-docs fixture");
389 assert!(fs.files.contains_key(".claude-plugin/plugin.json"));
390 assert!(fs.files.contains_key(".mcp.json"));
391 assert!(fs.files.contains_key("agents/docs-researcher.md"));
392 assert!(fs.files.contains_key("skills/microsoft-docs/SKILL.md"));
393 assert!(fs.files.contains_key("commands/ms-docs.md"));
394 }
395
396 #[test]
397 fn manifest_discovery_from_fixture() {
398 let fixture = std::path::Path::new(concat!(
399 env!("CARGO_MANIFEST_DIR"),
400 "/../../testdata/plugins/microsoft-docs"
401 ));
402 let fs = PluginFileSet::from_dir(fixture).unwrap();
403 let (manifest, warnings) = fs.manifest().unwrap();
404 assert_eq!(manifest.name, "microsoft-docs");
405 assert!(
406 warnings.iter().any(|w| w.contains("interface")),
407 "expected warning about 'interface' field, got: {warnings:?}"
408 );
409 }
410
411 #[test]
412 fn canonical_manifest_ignores_unknown_fields() {
413 let mut files = BTreeMap::new();
414 files.insert(
415 "plugin.json".to_string(),
416 br#"{
417 "$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
418 "name":"portable-plugin",
419 "futureField":true
420 }"#
421 .to_vec(),
422 );
423 let file_set = PluginFileSet::from_map("portable-plugin", files).unwrap();
424
425 let (manifest, warnings) = file_set.manifest().unwrap();
426
427 assert!(manifest.is_agent_plugins_v1());
428 assert!(!manifest.extra.contains_key("futureField"));
429 assert!(
430 warnings
431 .iter()
432 .any(|warning| warning.contains("futureField"))
433 );
434 }
435
436 #[test]
437 fn canonical_manifest_rejects_fatal_schema_violations() {
438 let mut files = BTreeMap::new();
439 files.insert(
440 "plugin.json".to_string(),
441 br#"{
442 "$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
443 "name":"portable-plugin",
444 "author":{"name":"Acme","unexpected":true}
445 }"#
446 .to_vec(),
447 );
448 let file_set = PluginFileSet::from_map("portable-plugin", files).unwrap();
449
450 let error = file_set.manifest().unwrap_err();
451
452 assert!(error.contains("invalid plugin.json"), "{error}");
453 }
454
455 #[test]
456 fn non_agent_root_manifest_does_not_mask_legacy_host_manifest() {
457 let mut files = BTreeMap::new();
458 files.insert(
459 "plugin.json".to_string(),
460 br#"{"name":"unrelated-package"}"#.to_vec(),
461 );
462 files.insert(
463 ".claude-plugin/plugin.json".to_string(),
464 br#"{"name":"legacy-plugin","description":"Legacy plugin"}"#.to_vec(),
465 );
466 let file_set = PluginFileSet::from_map("legacy-plugin", files).unwrap();
467
468 let (manifest, warnings) = file_set.manifest().unwrap();
469
470 assert_eq!(manifest.name, "legacy-plugin");
471 assert!(!manifest.is_agent_plugins_v1());
472 assert!(
473 warnings
474 .iter()
475 .any(|warning| warning.contains("root plugin.json"))
476 );
477 }
478
479 #[test]
480 fn synthesized_manifest_for_no_manifest_dir() {
481 let tmpdir = tempfile::tempdir().unwrap();
483 std::fs::write(tmpdir.path().join("hello.md"), b"# Hello").unwrap();
484 let plugin_dir = tmpdir.path().join("my-test-plugin");
486 std::fs::create_dir(&plugin_dir).unwrap();
487 std::fs::write(plugin_dir.join("README.md"), b"content").unwrap();
488 let fs = PluginFileSet::from_dir(&plugin_dir).unwrap();
489 let (manifest, warnings) = fs.manifest().unwrap();
490 assert_eq!(manifest.name, "my-test-plugin");
491 assert!(warnings.iter().any(|w| w.contains("no plugin.json")));
492 }
493
494 #[cfg(unix)]
495 #[test]
496 fn symlink_rejected_even_within_root() {
497 let tmpdir = tempfile::tempdir().unwrap();
498 let plugin_dir = tmpdir.path().join("my-plugin");
499 std::fs::create_dir(&plugin_dir).unwrap();
500 std::fs::write(plugin_dir.join("README.md"), b"content").unwrap();
501 std::os::unix::fs::symlink(plugin_dir.join("README.md"), plugin_dir.join("link.md"))
503 .unwrap();
504 let err = PluginFileSet::from_dir(&plugin_dir).unwrap_err();
505 assert!(
506 err.contains("symlink"),
507 "expected symlink error, got: {err}"
508 );
509 }
510
511 #[cfg(unix)]
512 #[test]
513 fn symlink_directory_cycle_rejected() {
514 let tmpdir = tempfile::tempdir().unwrap();
515 let plugin_dir = tmpdir.path().join("my-plugin");
516 std::fs::create_dir(&plugin_dir).unwrap();
517 std::fs::write(plugin_dir.join("README.md"), b"content").unwrap();
518 std::os::unix::fs::symlink(&plugin_dir, plugin_dir.join("loop")).unwrap();
520 let err = PluginFileSet::from_dir(&plugin_dir).unwrap_err();
521 assert!(
522 err.contains("symlink"),
523 "expected symlink error, got: {err}"
524 );
525 }
526
527 #[test]
528 fn oversized_file_rejected() {
529 let tmpdir = tempfile::tempdir().unwrap();
530 let big = vec![b'x'; MAX_PLUGIN_FILE_BYTES + 1];
531 std::fs::write(tmpdir.path().join("big.txt"), &big).unwrap();
532 let err = PluginFileSet::from_dir(tmpdir.path()).unwrap_err();
533 assert!(err.contains("exceeding the"), "error was: {err}");
534 }
535}