Skip to main content

agent_plugins/
fs.rs

1//! Loading plugins from real directories — the spec's package unit.
2//!
3//! [`DirSource`] answers the loader's questions with `std::fs`, including the
4//! one question only a filesystem can answer honestly: *confinement*. §4.1
5//! permits symlinks that resolve inside the plugin root and demands rejection
6//! of paths that resolve outside it, so [`DirSource::confined`] canonicalizes
7//! and compares against the canonicalized root.
8
9use std::io;
10use std::path::{Path, PathBuf};
11
12use crate::load::{DirEntry, FileKind, LoadedPlugin, Rejection};
13use crate::path::PackagePath;
14use crate::source::{self, Source};
15
16/// A plugin package rooted at a real directory.
17#[derive(Debug, Clone)]
18pub struct DirSource {
19    root: PathBuf,
20    canonical_root: PathBuf,
21}
22
23impl DirSource {
24    /// Open a plugin root. Fails if the directory does not exist or cannot
25    /// be canonicalized.
26    pub fn open(root: impl AsRef<Path>) -> io::Result<Self> {
27        let root = root.as_ref().to_path_buf();
28        let canonical_root = root.canonicalize()?;
29        Ok(Self { root, canonical_root })
30    }
31
32    /// The plugin root as given.
33    pub fn root(&self) -> &Path {
34        &self.root
35    }
36
37    /// The filesystem-resolved plugin root — the §4.1 containment boundary
38    /// and the value of `PLUGIN_ROOT` (§9.1).
39    pub fn canonical_root(&self) -> &Path {
40        &self.canonical_root
41    }
42
43    fn native(&self, path: &PackagePath) -> PathBuf {
44        path.to_native(&self.root)
45    }
46}
47
48impl Source for DirSource {
49    type Error = io::Error;
50
51    fn kind(&mut self, path: &PackagePath) -> Result<Option<FileKind>, io::Error> {
52        match std::fs::metadata(self.native(path)) {
53            Ok(meta) => Ok(Some(if meta.is_file() {
54                FileKind::File
55            } else if meta.is_dir() {
56                FileKind::Directory
57            } else {
58                FileKind::Other
59            })),
60            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
61            Err(e) => Err(e),
62        }
63    }
64
65    fn confined(&mut self, path: &PackagePath) -> Result<bool, io::Error> {
66        let resolved = self.native(path).canonicalize()?;
67        Ok(resolved.starts_with(&self.canonical_root))
68    }
69
70    fn read(&mut self, path: &PackagePath) -> Result<Vec<u8>, io::Error> {
71        std::fs::read(self.native(path))
72    }
73
74    fn list(&mut self, path: &PackagePath) -> Result<Vec<DirEntry>, io::Error> {
75        let mut entries = Vec::new();
76        for entry in std::fs::read_dir(self.native(path))? {
77            let entry = entry?;
78            let Ok(name) = entry.file_name().into_string() else {
79                continue; // non-UTF-8 names cannot be package paths
80            };
81            // Follow symlinks so a linked skill directory counts as one.
82            let kind = match std::fs::metadata(entry.path()) {
83                Ok(meta) if meta.is_file() => FileKind::File,
84                Ok(meta) if meta.is_dir() => FileKind::Directory,
85                _ => FileKind::Other,
86            };
87            entries.push(DirEntry::new(name, kind));
88        }
89        entries.sort_by(|a, b| a.name.cmp(&b.name));
90        Ok(entries)
91    }
92}
93
94/// Load the plugin rooted at `path` from the filesystem.
95///
96/// The one-call entry point:
97///
98/// ```no_run
99/// let plugin = agent_plugins::load_dir("path/to/my-plugin")?;
100/// println!("loaded {} with {} skill(s)", plugin.manifest.name, plugin.skills.len());
101/// # Ok::<(), agent_plugins::Rejection>(())
102/// ```
103pub fn load_dir(path: impl AsRef<Path>) -> Result<LoadedPlugin, Rejection> {
104    let mut source = DirSource::open(path)
105        .map_err(|e| Rejection::Source { detail: format!("cannot open plugin root: {e}") })?;
106    source::load(&mut source)
107}