1use std::collections::BTreeMap;
8use std::path::{Component, Path};
9
10use super::manifest::PluginManifest;
11
12pub const MAX_PLUGIN_FILES: usize = 256;
15pub const MAX_PLUGIN_FILE_BYTES: usize = 64 * 1024;
17pub const MAX_PLUGIN_TOTAL_BYTES: usize = 4 * 1024 * 1024; const MANIFEST_PATHS: &[&str] = &[
22 ".claude-plugin/plugin.json",
23 ".codex-plugin/plugin.json",
24 ".cursor-plugin/plugin.json",
25];
26
27#[derive(Debug, Clone)]
33pub struct PluginFileSet {
34 pub files: BTreeMap<String, Vec<u8>>,
36 pub dir_name: String,
38}
39
40impl PluginFileSet {
41 pub fn from_map(
47 dir_name: impl Into<String>,
48 files: BTreeMap<String, Vec<u8>>,
49 ) -> Result<Self, String> {
50 let mut total_bytes: usize = 0;
51 if files.len() > MAX_PLUGIN_FILES {
52 return Err(format!(
53 "plugin contains {} files, exceeding the {MAX_PLUGIN_FILES}-file limit",
54 files.len()
55 ));
56 }
57 for (path, bytes) in &files {
58 if path.starts_with('/') {
60 return Err(format!("plugin file path '{path}' must be relative"));
61 }
62 for component in std::path::Path::new(path).components() {
63 if component == Component::ParentDir {
64 return Err(format!(
65 "path traversal detected in plugin file map: '{path}'"
66 ));
67 }
68 }
69 let file_size = bytes.len();
70 if file_size > MAX_PLUGIN_FILE_BYTES {
71 return Err(format!(
72 "plugin file '{path}' is {file_size} bytes, exceeding the {MAX_PLUGIN_FILE_BYTES}-byte limit"
73 ));
74 }
75 total_bytes += file_size;
76 if total_bytes > MAX_PLUGIN_TOTAL_BYTES {
77 return Err(format!(
78 "plugin total size exceeds {MAX_PLUGIN_TOTAL_BYTES} bytes"
79 ));
80 }
81 }
82 Ok(Self {
83 files,
84 dir_name: dir_name.into(),
85 })
86 }
87
88 pub fn from_dir(path: &Path) -> Result<Self, String> {
95 let canonical_root = path.canonicalize().map_err(|e| {
96 format!(
97 "cannot canonicalize plugin directory {}: {}",
98 path.display(),
99 e
100 )
101 })?;
102
103 let dir_name = canonical_root
104 .file_name()
105 .and_then(|n| n.to_str())
106 .unwrap_or("plugin")
107 .to_string();
108
109 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
110 let mut total_bytes: usize = 0;
111
112 collect_dir(
113 &canonical_root,
114 &canonical_root,
115 &mut files,
116 &mut total_bytes,
117 )?;
118
119 Ok(Self { files, dir_name })
120 }
121
122 pub fn manifest(&self) -> Result<(PluginManifest, Vec<String>), String> {
128 for manifest_path in MANIFEST_PATHS {
129 if let Some(bytes) = self.files.get(*manifest_path) {
130 let text = std::str::from_utf8(bytes)
131 .map_err(|_| format!("{manifest_path} is not valid UTF-8"))?;
132 let manifest: PluginManifest = serde_json::from_str(text)
133 .map_err(|e| format!("failed to parse {manifest_path}: {e}"))?;
134 let mut warnings = Vec::new();
135 for key in manifest.extra.keys() {
136 warnings.push(format!(
137 "plugin manifest: unrecognized field '{key}' will be ignored"
138 ));
139 }
140 return Ok((manifest, warnings));
141 }
142 }
143
144 let name = dir_name_to_plugin_name(&self.dir_name);
146 Ok((
147 PluginManifest {
148 name,
149 display_name: None,
150 version: None,
151 description: None,
152 author: None,
153 homepage: None,
154 repository: None,
155 license: None,
156 keywords: Vec::new(),
157 skills: None,
158 commands: None,
159 agents: None,
160 mcp_servers: None,
161 extra: Default::default(),
162 },
163 vec!["no plugin.json manifest found; name derived from directory name".to_string()],
164 ))
165 }
166
167 pub fn text_file(&self, path: &str) -> Option<String> {
169 let bytes = self.files.get(path)?;
170 String::from_utf8(bytes.clone()).ok()
171 }
172
173 pub fn list_dir<'a>(&'a self, dir_prefix: &str) -> Vec<(&'a str, &'a str)> {
176 let prefix = if dir_prefix.ends_with('/') {
177 dir_prefix.to_string()
178 } else {
179 format!("{dir_prefix}/")
180 };
181 self.files
182 .keys()
183 .filter_map(|k| {
184 let rest = k.strip_prefix(&prefix)?;
185 if rest.is_empty() || rest.contains('/') {
186 None
187 } else {
188 Some((rest, k.as_str()))
189 }
190 })
191 .collect()
192 }
193
194 pub fn list_dir_recursive<'a>(&'a self, dir_prefix: &str) -> Vec<&'a str> {
196 let prefix = if dir_prefix.ends_with('/') {
197 dir_prefix.to_string()
198 } else {
199 format!("{dir_prefix}/")
200 };
201 self.files
202 .keys()
203 .filter(|k| k.starts_with(&prefix))
204 .map(|k| k.as_str())
205 .collect()
206 }
207}
208
209fn dir_name_to_plugin_name(name: &str) -> String {
211 let lower = name.to_lowercase();
212 let result: String = lower
214 .chars()
215 .map(|c| {
216 if c.is_ascii_lowercase() || c.is_ascii_digit() {
217 c
218 } else {
219 '-'
220 }
221 })
222 .collect();
223 let mut out = String::new();
225 let mut prev_was_hyphen = true; for ch in result.chars() {
227 if ch == '-' {
228 if !prev_was_hyphen {
229 out.push(ch);
230 }
231 prev_was_hyphen = true;
232 } else {
233 out.push(ch);
234 prev_was_hyphen = false;
235 }
236 }
237 let out = out.trim_end_matches('-');
239 if out.is_empty() {
240 "plugin".to_string()
241 } else {
242 out.to_string()
243 }
244}
245
246fn collect_dir(
248 root: &Path,
249 current: &Path,
250 files: &mut BTreeMap<String, Vec<u8>>,
251 total_bytes: &mut usize,
252) -> Result<(), String> {
253 let entries = std::fs::read_dir(current)
254 .map_err(|e| format!("cannot read directory {}: {}", current.display(), e))?;
255
256 for entry_result in entries {
257 let entry = entry_result.map_err(|e| {
258 format!(
259 "error reading directory entry in {}: {}",
260 current.display(),
261 e
262 )
263 })?;
264 let entry_path = entry.path();
265
266 let metadata = entry_path
268 .symlink_metadata()
269 .map_err(|e| format!("cannot stat {}: {}", entry_path.display(), e))?;
270 if metadata.file_type().is_symlink() {
271 return Err(format!(
276 "symlink {} is not allowed in a plugin directory",
277 entry_path.display()
278 ));
279 }
280
281 let rel = entry_path.strip_prefix(root).map_err(|_| {
283 format!(
284 "path {} is not under root {}",
285 entry_path.display(),
286 root.display()
287 )
288 })?;
289
290 for component in rel.components() {
292 if component == Component::ParentDir {
293 return Err(format!(
294 "path traversal detected in plugin directory: {}",
295 entry_path.display()
296 ));
297 }
298 }
299
300 let rel_str = rel.to_string_lossy().replace('\\', "/");
301
302 if metadata.is_dir() {
303 collect_dir(root, &entry_path, files, total_bytes)?;
304 } else {
305 let file_size = metadata.len() as usize;
307 if file_size > MAX_PLUGIN_FILE_BYTES {
308 return Err(format!(
312 "plugin file '{rel_str}' is {file_size} bytes, exceeding the {MAX_PLUGIN_FILE_BYTES}-byte limit"
313 ));
314 }
315 *total_bytes += file_size;
316 if *total_bytes > MAX_PLUGIN_TOTAL_BYTES {
317 return Err(format!(
318 "plugin directory total size exceeds {MAX_PLUGIN_TOTAL_BYTES} bytes"
319 ));
320 }
321 if files.len() >= MAX_PLUGIN_FILES {
322 return Err(format!(
323 "plugin directory contains more than {MAX_PLUGIN_FILES} files"
324 ));
325 }
326 let content = std::fs::read(&entry_path)
327 .map_err(|e| format!("cannot read {}: {}", entry_path.display(), e))?;
328 files.insert(rel_str, content);
329 }
330 }
331
332 Ok(())
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn dir_name_to_plugin_name_simple() {
341 assert_eq!(dir_name_to_plugin_name("microsoft-docs"), "microsoft-docs");
342 assert_eq!(dir_name_to_plugin_name("MyPlugin"), "myplugin");
343 assert_eq!(dir_name_to_plugin_name("my_plugin"), "my-plugin");
344 assert_eq!(dir_name_to_plugin_name("---test---"), "test");
345 assert_eq!(dir_name_to_plugin_name("my plugin"), "my-plugin");
346 }
347
348 #[test]
349 fn plugin_file_set_from_fixture() {
350 let fixture = std::path::Path::new(concat!(
351 env!("CARGO_MANIFEST_DIR"),
352 "/../../testdata/plugins/microsoft-docs"
353 ));
354 let fs = PluginFileSet::from_dir(fixture).expect("should load microsoft-docs fixture");
355 assert!(fs.files.contains_key(".claude-plugin/plugin.json"));
356 assert!(fs.files.contains_key(".mcp.json"));
357 assert!(fs.files.contains_key("agents/docs-researcher.md"));
358 assert!(fs.files.contains_key("skills/microsoft-docs/SKILL.md"));
359 assert!(fs.files.contains_key("commands/ms-docs.md"));
360 }
361
362 #[test]
363 fn manifest_discovery_from_fixture() {
364 let fixture = std::path::Path::new(concat!(
365 env!("CARGO_MANIFEST_DIR"),
366 "/../../testdata/plugins/microsoft-docs"
367 ));
368 let fs = PluginFileSet::from_dir(fixture).unwrap();
369 let (manifest, warnings) = fs.manifest().unwrap();
370 assert_eq!(manifest.name, "microsoft-docs");
371 assert!(
372 warnings.iter().any(|w| w.contains("interface")),
373 "expected warning about 'interface' field, got: {warnings:?}"
374 );
375 }
376
377 #[test]
378 fn synthesized_manifest_for_no_manifest_dir() {
379 let tmpdir = tempfile::tempdir().unwrap();
381 std::fs::write(tmpdir.path().join("hello.md"), b"# Hello").unwrap();
382 let plugin_dir = tmpdir.path().join("my-test-plugin");
384 std::fs::create_dir(&plugin_dir).unwrap();
385 std::fs::write(plugin_dir.join("README.md"), b"content").unwrap();
386 let fs = PluginFileSet::from_dir(&plugin_dir).unwrap();
387 let (manifest, warnings) = fs.manifest().unwrap();
388 assert_eq!(manifest.name, "my-test-plugin");
389 assert!(warnings.iter().any(|w| w.contains("no plugin.json")));
390 }
391
392 #[cfg(unix)]
393 #[test]
394 fn symlink_rejected_even_within_root() {
395 let tmpdir = tempfile::tempdir().unwrap();
396 let plugin_dir = tmpdir.path().join("my-plugin");
397 std::fs::create_dir(&plugin_dir).unwrap();
398 std::fs::write(plugin_dir.join("README.md"), b"content").unwrap();
399 std::os::unix::fs::symlink(plugin_dir.join("README.md"), plugin_dir.join("link.md"))
401 .unwrap();
402 let err = PluginFileSet::from_dir(&plugin_dir).unwrap_err();
403 assert!(
404 err.contains("symlink"),
405 "expected symlink error, got: {err}"
406 );
407 }
408
409 #[cfg(unix)]
410 #[test]
411 fn symlink_directory_cycle_rejected() {
412 let tmpdir = tempfile::tempdir().unwrap();
413 let plugin_dir = tmpdir.path().join("my-plugin");
414 std::fs::create_dir(&plugin_dir).unwrap();
415 std::fs::write(plugin_dir.join("README.md"), b"content").unwrap();
416 std::os::unix::fs::symlink(&plugin_dir, plugin_dir.join("loop")).unwrap();
418 let err = PluginFileSet::from_dir(&plugin_dir).unwrap_err();
419 assert!(
420 err.contains("symlink"),
421 "expected symlink error, got: {err}"
422 );
423 }
424
425 #[test]
426 fn oversized_file_rejected() {
427 let tmpdir = tempfile::tempdir().unwrap();
428 let big = vec![b'x'; MAX_PLUGIN_FILE_BYTES + 1];
429 std::fs::write(tmpdir.path().join("big.txt"), &big).unwrap();
430 let err = PluginFileSet::from_dir(tmpdir.path()).unwrap_err();
431 assert!(err.contains("exceeding the"), "error was: {err}");
432 }
433}