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::{
11    AGENT_PLUGINS_V1_MANIFEST_SCHEMA, PluginManifest, parse_agent_plugins_v1_manifest,
12};
13
14// Package ingestion limits. Compiled text contributions still pass the
15// declarative capability's stricter per-component validation.
16/// Maximum number of files in a plugin directory.
17pub const MAX_PLUGIN_FILES: usize = 256;
18/// Maximum bytes per individual file.
19pub const MAX_PLUGIN_FILE_BYTES: usize = 128 * 1024;
20/// Maximum total bytes across all files.
21pub const MAX_PLUGIN_TOTAL_BYTES: usize = 4 * 1024 * 1024; // 4 MB
22
23/// Manifest discovery priority order.
24const MANIFEST_PATHS: &[&str] = &[
25    ".claude-plugin/plugin.json",
26    ".codex-plugin/plugin.json",
27    ".cursor-plugin/plugin.json",
28];
29
30/// In-memory representation of a loaded plugin directory.
31///
32/// Relative path → raw bytes for every file within the plugin directory.
33/// The map is a `BTreeMap` so iteration order is deterministic (useful for
34/// tests and for reproducing compilation results across runs).
35#[derive(Debug, Clone)]
36pub struct PluginFileSet {
37    /// All files, keyed by relative path (forward-slash separated, no leading slash).
38    pub files: BTreeMap<String, Vec<u8>>,
39    /// The directory name (used for manifest synthesis when no manifest is found).
40    pub dir_name: String,
41}
42
43impl PluginFileSet {
44    /// Build a `PluginFileSet` from an in-memory map of relative path → bytes.
45    ///
46    /// Applies the same per-file, total-size, and count limits as `from_dir`.
47    /// Rejects any path that contains `..` components or an absolute leading `/`.
48    /// This is the seam for tarball extraction and tests — no disk access required.
49    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            // Reject absolute paths and traversals.
62            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    /// Load a plugin directory from disk.
92    ///
93    /// - Rejects `..` components and all symlinks (cycle/escape defense).
94    /// - Skips files larger than `MAX_PLUGIN_FILE_BYTES`.
95    /// - Fails if more than `MAX_PLUGIN_FILES` files are found.
96    /// - Fails if total bytes exceed `MAX_PLUGIN_TOTAL_BYTES`.
97    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    /// Resolve the plugin manifest.
126    ///
127    /// A canonical root `plugin.json` takes precedence when it declares the
128    /// Agent Plugins schema. Otherwise discovery falls back to the legacy
129    /// `.claude-plugin`, `.codex-plugin`, and `.cursor-plugin` manifests. If no
130    /// manifest is found, a minimal one is synthesized from the directory name.
131    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        // Synthesize a minimal manifest from the directory name.
176        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    /// Retrieve a file's content as a UTF-8 string, or `None` if not found or binary.
202    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    /// List relative paths that are direct children of `dir_prefix/`.
208    /// Returns `(relative_within_dir, full_relative_path)`.
209    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    /// List relative paths for all files under `dir_prefix/` (recursively).
229    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
243/// Convert a filesystem directory name into a valid plugin name (kebab-case).
244fn dir_name_to_plugin_name(name: &str) -> String {
245    let lower = name.to_lowercase();
246    // Replace anything that isn't [a-z0-9-] with a hyphen, then trim leading/trailing hyphens.
247    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    // Collapse runs of hyphens and strip leading/trailing hyphens.
258    let mut out = String::new();
259    let mut prev_was_hyphen = true; // start true so leading hyphens are stripped
260    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    // Strip trailing hyphen.
272    let out = out.trim_end_matches('-');
273    if out.is_empty() {
274        "plugin".to_string()
275    } else {
276        out.to_string()
277    }
278}
279
280/// Recursively collect files from `current` into `files`.
281fn 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        // Reject symlinks.
301        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            // Reject symlinks outright: even an in-root link can form a
306            // directory cycle (unbounded traversal), and tarball extraction
307            // already skips link entries — keep both ingestion paths
308            // consistent.
309            return Err(format!(
310                "symlink {} is not allowed in a plugin directory",
311                entry_path.display()
312            ));
313        }
314
315        // Build a relative path (forward-slash, no leading slash).
316        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        // Validate that no path component is `..`.
325        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            // It's a file.
340            let file_size = metadata.len() as usize;
341            if file_size > MAX_PLUGIN_FILE_BYTES {
342                // Skip oversized files with a note (caller decides whether to warn).
343                // We signal this by recording an empty entry under a sentinel path.
344                // Instead, return an error so compile_plugin can decide.
345                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        // Use a temp dir with no plugin.json.
482        let tmpdir = tempfile::tempdir().unwrap();
483        std::fs::write(tmpdir.path().join("hello.md"), b"# Hello").unwrap();
484        // Rename the temp dir to have a known name by creating a sub-dir.
485        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        // In-root symlink: previously tolerated, now rejected (cycle defense).
502        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        // Link back to the plugin root: would recurse forever if followed.
519        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}