Skip to main content

everruns_core/plugins/
file_set.rs

1// Plugin file set: in-memory representation of a plugin directory.
2//
3// PluginFileSet walks a directory on disk and captures its contents as a map
4// of relative path → bytes, subject to size and count limits mirroring those
5// of the declarative capability system.
6
7use std::collections::BTreeMap;
8use std::path::{Component, Path};
9
10use super::manifest::PluginManifest;
11
12// Size / count limits (mirror declarative capability limits).
13/// Maximum number of files in a plugin directory.
14pub const MAX_PLUGIN_FILES: usize = 256;
15/// Maximum bytes per individual file.
16pub const MAX_PLUGIN_FILE_BYTES: usize = 64 * 1024;
17/// Maximum total bytes across all files.
18pub const MAX_PLUGIN_TOTAL_BYTES: usize = 4 * 1024 * 1024; // 4 MB
19
20/// Manifest discovery priority order.
21const MANIFEST_PATHS: &[&str] = &[
22    ".claude-plugin/plugin.json",
23    ".codex-plugin/plugin.json",
24    ".cursor-plugin/plugin.json",
25];
26
27/// In-memory representation of a loaded plugin directory.
28///
29/// Relative path → raw bytes for every file within the plugin directory.
30/// The map is a `BTreeMap` so iteration order is deterministic (useful for
31/// tests and for reproducing compilation results across runs).
32#[derive(Debug, Clone)]
33pub struct PluginFileSet {
34    /// All files, keyed by relative path (forward-slash separated, no leading slash).
35    pub files: BTreeMap<String, Vec<u8>>,
36    /// The directory name (used for manifest synthesis when no manifest is found).
37    pub dir_name: String,
38}
39
40impl PluginFileSet {
41    /// Build a `PluginFileSet` from an in-memory map of relative path → bytes.
42    ///
43    /// Applies the same per-file, total-size, and count limits as `from_dir`.
44    /// Rejects any path that contains `..` components or an absolute leading `/`.
45    /// This is the seam for tarball extraction and tests — no disk access required.
46    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            // Reject absolute paths and traversals.
59            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    /// Load a plugin directory from disk.
89    ///
90    /// - Rejects `..` components and all symlinks (cycle/escape defense).
91    /// - Skips files larger than `MAX_PLUGIN_FILE_BYTES`.
92    /// - Fails if more than `MAX_PLUGIN_FILES` files are found.
93    /// - Fails if total bytes exceed `MAX_PLUGIN_TOTAL_BYTES`.
94    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    /// Resolve the plugin manifest.
123    ///
124    /// Discovery order: `.claude-plugin/plugin.json`, then `.codex-plugin/plugin.json`,
125    /// then `.cursor-plugin/plugin.json`. If none is found, a minimal manifest
126    /// is synthesized from the directory name (Claude Code parity).
127    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        // Synthesize a minimal manifest from the directory name.
145        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    /// Retrieve a file's content as a UTF-8 string, or `None` if not found or binary.
168    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    /// List relative paths that are direct children of `dir_prefix/`.
174    /// Returns `(relative_within_dir, full_relative_path)`.
175    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    /// List relative paths for all files under `dir_prefix/` (recursively).
195    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
209/// Convert a filesystem directory name into a valid plugin name (kebab-case).
210fn dir_name_to_plugin_name(name: &str) -> String {
211    let lower = name.to_lowercase();
212    // Replace anything that isn't [a-z0-9-] with a hyphen, then trim leading/trailing hyphens.
213    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    // Collapse runs of hyphens and strip leading/trailing hyphens.
224    let mut out = String::new();
225    let mut prev_was_hyphen = true; // start true so leading hyphens are stripped
226    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    // Strip trailing hyphen.
238    let out = out.trim_end_matches('-');
239    if out.is_empty() {
240        "plugin".to_string()
241    } else {
242        out.to_string()
243    }
244}
245
246/// Recursively collect files from `current` into `files`.
247fn 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        // Reject symlinks.
267        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            // Reject symlinks outright: even an in-root link can form a
272            // directory cycle (unbounded traversal), and tarball extraction
273            // already skips link entries — keep both ingestion paths
274            // consistent.
275            return Err(format!(
276                "symlink {} is not allowed in a plugin directory",
277                entry_path.display()
278            ));
279        }
280
281        // Build a relative path (forward-slash, no leading slash).
282        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        // Validate that no path component is `..`.
291        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            // It's a file.
306            let file_size = metadata.len() as usize;
307            if file_size > MAX_PLUGIN_FILE_BYTES {
308                // Skip oversized files with a note (caller decides whether to warn).
309                // We signal this by recording an empty entry under a sentinel path.
310                // Instead, return an error so compile_plugin can decide.
311                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        // Use a temp dir with no plugin.json.
380        let tmpdir = tempfile::tempdir().unwrap();
381        std::fs::write(tmpdir.path().join("hello.md"), b"# Hello").unwrap();
382        // Rename the temp dir to have a known name by creating a sub-dir.
383        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        // In-root symlink: previously tolerated, now rejected (cycle defense).
400        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        // Link back to the plugin root: would recurse forever if followed.
417        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}