agent-plugins 0.1.1

Sans-IO implementation of the Agent Plugins specification (agent-plugins.org): load, validate, and prepare agent plugins from any IO
Documentation
//! Loading plugins from real directories — the spec's package unit.
//!
//! [`DirSource`] answers the loader's questions with `std::fs`, including the
//! one question only a filesystem can answer honestly: *confinement*. §4.1
//! permits symlinks that resolve inside the plugin root and demands rejection
//! of paths that resolve outside it, so [`DirSource::confined`] canonicalizes
//! and compares against the canonicalized root.

use std::io;
use std::path::{Path, PathBuf};

use crate::load::{DirEntry, FileKind, LoadedPlugin, Rejection};
use crate::path::PackagePath;
use crate::source::{self, Source};

/// A plugin package rooted at a real directory.
#[derive(Debug, Clone)]
pub struct DirSource {
    root: PathBuf,
    canonical_root: PathBuf,
}

impl DirSource {
    /// Open a plugin root. Fails if the directory does not exist or cannot
    /// be canonicalized.
    pub fn open(root: impl AsRef<Path>) -> io::Result<Self> {
        let root = root.as_ref().to_path_buf();
        let canonical_root = root.canonicalize()?;
        Ok(Self { root, canonical_root })
    }

    /// The plugin root as given.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// The filesystem-resolved plugin root — the §4.1 containment boundary
    /// and the value of `PLUGIN_ROOT` (§9.1).
    pub fn canonical_root(&self) -> &Path {
        &self.canonical_root
    }

    fn native(&self, path: &PackagePath) -> PathBuf {
        path.to_native(&self.root)
    }
}

impl Source for DirSource {
    type Error = io::Error;

    fn kind(&mut self, path: &PackagePath) -> Result<Option<FileKind>, io::Error> {
        match std::fs::metadata(self.native(path)) {
            Ok(meta) => Ok(Some(if meta.is_file() {
                FileKind::File
            } else if meta.is_dir() {
                FileKind::Directory
            } else {
                FileKind::Other
            })),
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e),
        }
    }

    fn confined(&mut self, path: &PackagePath) -> Result<bool, io::Error> {
        let resolved = self.native(path).canonicalize()?;
        Ok(resolved.starts_with(&self.canonical_root))
    }

    fn read(&mut self, path: &PackagePath) -> Result<Vec<u8>, io::Error> {
        std::fs::read(self.native(path))
    }

    fn list(&mut self, path: &PackagePath) -> Result<Vec<DirEntry>, io::Error> {
        let mut entries = Vec::new();
        for entry in std::fs::read_dir(self.native(path))? {
            let entry = entry?;
            let Ok(name) = entry.file_name().into_string() else {
                continue; // non-UTF-8 names cannot be package paths
            };
            // Follow symlinks so a linked skill directory counts as one.
            let kind = match std::fs::metadata(entry.path()) {
                Ok(meta) if meta.is_file() => FileKind::File,
                Ok(meta) if meta.is_dir() => FileKind::Directory,
                _ => FileKind::Other,
            };
            entries.push(DirEntry::new(name, kind));
        }
        entries.sort_by(|a, b| a.name.cmp(&b.name));
        Ok(entries)
    }
}

/// Load the plugin rooted at `path` from the filesystem.
///
/// The one-call entry point:
///
/// ```no_run
/// let plugin = agent_plugins::load_dir("path/to/my-plugin")?;
/// println!("loaded {} with {} skill(s)", plugin.manifest.name, plugin.skills.len());
/// # Ok::<(), agent_plugins::Rejection>(())
/// ```
pub fn load_dir(path: impl AsRef<Path>) -> Result<LoadedPlugin, Rejection> {
    let mut source = DirSource::open(path)
        .map_err(|e| Rejection::Source { detail: format!("cannot open plugin root: {e}") })?;
    source::load(&mut source)
}