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 fn file_set(files: &[(&str, &[u8])]) -> PluginFileSet {
374 PluginFileSet::from_map(
375 "test",
376 files
377 .iter()
378 .map(|(p, b)| (p.to_string(), b.to_vec()))
379 .collect(),
380 )
381 .unwrap()
382 }
383
384 #[test]
385 fn missing_manifest_synthesizes_normalized_name_and_warning() {
386 for (directory, expected) in [
387 ("microsoft-docs", "microsoft-docs"),
388 ("MyPlugin", "myplugin"),
389 ("my_plugin", "my-plugin"),
390 ("---test---", "test"),
391 ("my plugin", "my-plugin"),
392 ("---💡---", "plugin"),
393 ("", "plugin"),
394 ] {
395 let fs = PluginFileSet::from_map(directory, BTreeMap::new()).unwrap();
396 let (manifest, warnings) = fs.manifest().unwrap();
397 assert_eq!(
398 serde_json::to_value(manifest).unwrap(),
399 serde_json::json!({"name":expected})
400 );
401 assert_eq!(
402 warnings,
403 ["no plugin.json manifest found; name derived from directory name"]
404 );
405 }
406 }
407
408 #[test]
409 fn fixture_load_preserves_all_files_and_discovers_legacy_manifest() {
410 let fixture = Path::new(concat!(
411 env!("CARGO_MANIFEST_DIR"),
412 "/testdata/plugins/microsoft-docs"
413 ));
414 let fs = PluginFileSet::from_dir(fixture).unwrap();
415 assert_eq!(fs.dir_name, "microsoft-docs");
416 assert_eq!(
417 fs.files.keys().map(String::as_str).collect::<Vec<_>>(),
418 [
419 ".claude-plugin/plugin.json",
420 ".mcp.json",
421 "agents/docs-researcher.md",
422 "assets/icon.svg",
423 "commands/ms-docs.md",
424 "skills/microsoft-docs/SKILL.md"
425 ]
426 );
427 assert!(fs.files.values().all(|bytes| !bytes.is_empty()));
428 let (manifest, warnings) = fs.manifest().unwrap();
429 assert_eq!(manifest.name, "microsoft-docs");
430 assert_eq!(manifest.display_name.as_deref(), Some("Microsoft Docs"));
431 assert_eq!(manifest.version.as_deref(), Some("0.1.0"));
432 assert_eq!(manifest.icon.as_deref(), Some("./assets/icon.svg"));
433 assert_eq!(
434 warnings,
435 ["plugin manifest: unrecognized field 'interface' will be ignored"]
436 );
437 }
438
439 #[test]
440 fn manifest_priority_and_schema_failures_do_not_silently_fall_back() {
441 let mut fs = file_set(&[
442 ("plugin.json", br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"portable-plugin","futureField":true}"#),
443 (".claude-plugin/plugin.json", br#"{"name":"claude"}"#),
444 (".codex-plugin/plugin.json", br#"{"name":"codex"}"#),
445 (".cursor-plugin/plugin.json", br#"{"name":"cursor"}"#),
446 ]);
447 let (manifest, warnings) = fs.manifest().unwrap();
448 assert_eq!(manifest.name, "portable-plugin");
449 assert!(manifest.is_agent_plugins_v1());
450 assert!(manifest.extra.is_empty());
451 assert_eq!(
452 warnings,
453 ["plugin.json: unrecognized field 'futureField' was ignored"]
454 );
455 fs.files.insert("plugin.json".into(), br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"portable-plugin","author":{"name":"Acme","unexpected":true}}"#.to_vec());
456 assert!(
457 fs.manifest()
458 .unwrap_err()
459 .starts_with("invalid plugin.json: /author:")
460 );
461 fs.files.insert("plugin.json".into(), br#"{"$schema":"https://agent-plugins.org/schemas/2.0.0/plugin.schema.json","name":"future"}"#.to_vec());
462 assert_eq!(
463 fs.manifest().unwrap_err(),
464 "unsupported Agent Plugins schema 'https://agent-plugins.org/schemas/2.0.0/plugin.schema.json'; supported schema is https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
465 );
466 fs.files.insert(
467 "plugin.json".into(),
468 br#"{"name":"unrelated-package"}"#.to_vec(),
469 );
470 let (manifest, warnings) = fs.manifest().unwrap();
471 assert_eq!(manifest.name, "claude");
472 assert!(!manifest.is_agent_plugins_v1());
473 assert_eq!(
474 warnings,
475 ["root plugin.json does not declare an Agent Plugins schema and was ignored"]
476 );
477 fs.files.remove("plugin.json");
478 for (path, name) in [
479 (".claude-plugin/plugin.json", "claude"),
480 (".codex-plugin/plugin.json", "codex"),
481 (".cursor-plugin/plugin.json", "cursor"),
482 ] {
483 let (manifest, warnings) = fs.manifest().unwrap();
484 assert_eq!(manifest.name, name);
485 assert!(warnings.is_empty());
486 fs.files.insert(path.into(), vec![0xff]);
487 assert_eq!(
488 fs.manifest().unwrap_err(),
489 format!("{path} is not valid UTF-8")
490 );
491 fs.files.remove(path);
492 }
493 fs.files.insert("plugin.json".into(), vec![0xff]);
494 assert_eq!(fs.manifest().unwrap_err(), "plugin.json is not valid UTF-8");
495 }
496
497 #[test]
498 fn map_paths_and_text_listing_preserve_content_and_directory_boundaries() {
499 for path in ["/absolute", "../outside", "a/../../outside"] {
500 let error = PluginFileSet::from_map("test", BTreeMap::from([(path.into(), vec![])]))
501 .unwrap_err();
502 assert!(error.contains(path), "{error}");
503 assert!(
504 error.contains("relative") || error.contains("path traversal"),
505 "{error}"
506 );
507 }
508 let fs = file_set(&[
509 ("skills/z.md", b"last\r\n"),
510 ("skills/a.md", "first é".as_bytes()),
511 ("skills/sub/b.bin", &[0xff]),
512 ("skills-extra/no.md", b"no"),
513 ]);
514 assert_eq!(fs.text_file("skills/a.md").as_deref(), Some("first é"));
515 assert_eq!(fs.text_file("skills/z.md").as_deref(), Some("last\r\n"));
516 assert_eq!(fs.text_file("missing"), None);
517 assert_eq!(fs.text_file("skills/sub/b.bin"), None);
518 for prefix in ["skills", "skills/"] {
519 assert_eq!(
520 fs.list_dir(prefix),
521 [("a.md", "skills/a.md"), ("z.md", "skills/z.md")]
522 );
523 assert_eq!(
524 fs.list_dir_recursive(prefix),
525 ["skills/a.md", "skills/sub/b.bin", "skills/z.md"]
526 );
527 }
528 assert!(fs.list_dir("missing").is_empty());
529 assert!(fs.list_dir_recursive("missing").is_empty());
530 }
531
532 #[test]
533 fn map_and_disk_enforce_literal_count_file_and_total_size_boundaries() {
534 for (count, bytes, extra, expected_error) in [
535 (256, 0, false, None),
536 (257, 0, false, Some("256")),
537 (1, 131072, false, None),
538 (1, 131073, false, Some("131072-byte limit")),
539 (32, 131072, false, None),
540 (32, 131072, true, Some("4194304 bytes")),
541 ] {
542 let mut files: BTreeMap<String, Vec<u8>> = (0..count)
543 .map(|i| (format!("file-{i:03}"), vec![b'x'; bytes]))
544 .collect();
545 if extra {
546 files.insert("extra".into(), vec![b'y']);
547 }
548 let tmp = tempfile::tempdir().unwrap();
549 for (path, content) in &files {
550 std::fs::write(tmp.path().join(path), content).unwrap();
551 }
552 for result in [
553 PluginFileSet::from_map("test", files.clone()),
554 PluginFileSet::from_dir(tmp.path()),
555 ] {
556 match expected_error {
557 Some(message) => assert!(result.unwrap_err().contains(message)),
558 None => assert_eq!(result.unwrap().files, files),
559 }
560 }
561 }
562 }
563
564 #[cfg(unix)]
565 #[test]
566 fn disk_rejects_file_links_directory_cycles_and_escapes() {
567 for target in ["inside", "cycle", "outside", "missing"] {
568 let tmp = tempfile::tempdir().unwrap();
569 let plugin = tmp.path().join("plugin");
570 std::fs::create_dir(&plugin).unwrap();
571 std::fs::write(plugin.join("README.md"), b"content").unwrap();
572 std::fs::write(tmp.path().join("outside"), b"secret").unwrap();
573 let destination = match target {
574 "inside" => plugin.join("README.md"),
575 "cycle" => plugin.clone(),
576 other => tmp.path().join(other),
577 };
578 std::os::unix::fs::symlink(destination, plugin.join("link")).unwrap();
579 assert_eq!(
580 PluginFileSet::from_dir(&plugin).unwrap_err(),
581 format!(
582 "symlink {} is not allowed in a plugin directory",
583 plugin.canonicalize().unwrap().join("link").display()
584 )
585 );
586 }
587 }
588}