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    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}