Skip to main content

agent_plugins/
path.rs

1//! Paths *inside* a plugin package.
2//!
3//! The specification talks about two kinds of paths and this module gives each
4//! its own type, so the distinction cannot be lost in a `String`:
5//!
6//! * [`PackagePath`] — where a file lives inside the package (`plugin.json`,
7//!   `skills/deploy/SKILL.md`). Always relative to the plugin root, always
8//!   `/`-separated, never escaping. Only this crate constructs them, from the
9//!   fixed component locations of §6.1 and from directory listings.
10//! * [`RelativePath`] — a *configured* plugin-relative path (§4.1): user input
11//!   that must begin with `./` and must stay inside the plugin root. Parsing
12//!   one is fallible; holding one is proof it passed.
13
14use std::fmt;
15
16/// A `/`-separated path to a file or directory inside a plugin package,
17/// relative to the plugin root.
18///
19/// Constructed only by the loader — from spec-fixed locations and from names
20/// returned by directory listings — so a `PackagePath` handed to an IO
21/// implementation is always well-formed and root-relative.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct PackagePath(String);
24
25impl PackagePath {
26    pub(crate) fn new(path: impl Into<String>) -> Self {
27        Self(path.into())
28    }
29
30    pub(crate) fn child(&self, name: &str) -> Self {
31        Self(format!("{}/{}", self.0, name))
32    }
33
34    /// The path as its canonical `/`-separated string.
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38
39    /// Join onto a native root directory, converting `/` to the platform
40    /// separator component by component.
41    pub fn to_native(&self, root: &std::path::Path) -> std::path::PathBuf {
42        let mut out = root.to_path_buf();
43        for segment in self.0.split('/') {
44            out.push(segment);
45        }
46        out
47    }
48}
49
50impl fmt::Display for PackagePath {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.write_str(&self.0)
53    }
54}
55
56impl AsRef<str> for PackagePath {
57    fn as_ref(&self) -> &str {
58        &self.0
59    }
60}
61
62/// A configured plugin-relative path: begins with `./` and, resolved purely
63/// lexically, never leaves the plugin root (§4.1).
64///
65/// This is the type for values a plugin *author* wrote — an MCP `command` of
66/// `./bin/server`, a `cwd` of `./data`. Successful parsing is the containment
67/// proof; APIs that need a safe path ask for a `RelativePath`, not a string.
68///
69/// Lexical containment is necessary but not sufficient: symlinks can still
70/// escape at the filesystem level, which is why loading also asks its IO
71/// source whether the *resolved* path stays confined (§4.1 rule 3).
72#[derive(Debug, Clone, PartialEq, Eq, Hash)]
73pub struct RelativePath(String);
74
75/// Why a string was refused as a plugin-relative path.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum RelativePathError {
78    /// The path does not begin with `./` (§4.1 rule 4).
79    MissingDotSlash,
80    /// Traversal (`..`) would resolve outside the plugin root.
81    EscapesRoot,
82}
83
84impl fmt::Display for RelativePathError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            Self::MissingDotSlash => f.write_str("plugin-relative paths must begin with `./`"),
88            Self::EscapesRoot => f.write_str("path resolves outside the plugin root"),
89        }
90    }
91}
92
93impl std::error::Error for RelativePathError {}
94
95impl RelativePath {
96    /// Parse a configured value as a plugin-relative path.
97    pub fn parse(raw: &str) -> Result<Self, RelativePathError> {
98        let Some(rest) = raw.strip_prefix("./") else {
99            return Err(RelativePathError::MissingDotSlash);
100        };
101        if !lexically_contained(rest) {
102            return Err(RelativePathError::EscapesRoot);
103        }
104        Ok(Self(raw.to_owned()))
105    }
106
107    /// The path exactly as written, including the leading `./`.
108    pub fn as_str(&self) -> &str {
109        &self.0
110    }
111
112    /// Resolve against a native plugin root.
113    pub fn resolve(&self, plugin_root: &std::path::Path) -> std::path::PathBuf {
114        let mut out = plugin_root.to_path_buf();
115        for segment in self.0[2..].split('/').filter(|s| !s.is_empty() && *s != ".") {
116            if segment == ".." {
117                out.pop();
118            } else {
119                out.push(segment);
120            }
121        }
122        out
123    }
124}
125
126impl fmt::Display for RelativePath {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.write_str(&self.0)
129    }
130}
131
132impl std::str::FromStr for RelativePath {
133    type Err = RelativePathError;
134
135    fn from_str(s: &str) -> Result<Self, Self::Err> {
136        Self::parse(s)
137    }
138}
139
140/// Would this `/`-separated path, resolved lexically, stay at or below its
141/// starting directory? (`a/../b` yes, `..` no, `a/../../b` no.)
142pub(crate) fn lexically_contained(path: &str) -> bool {
143    let mut depth: i32 = 0;
144    for segment in path.split('/') {
145        match segment {
146            "" | "." => {}
147            ".." => {
148                depth -= 1;
149                if depth < 0 {
150                    return false;
151                }
152            }
153            _ => depth += 1,
154        }
155    }
156    true
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn relative_paths_require_dot_slash() {
165        assert_eq!(RelativePath::parse("bin/server"), Err(RelativePathError::MissingDotSlash));
166        assert_eq!(RelativePath::parse("../bin/server"), Err(RelativePathError::MissingDotSlash));
167        assert!(RelativePath::parse("./bin/server").is_ok());
168    }
169
170    #[test]
171    fn relative_paths_must_stay_inside() {
172        assert!(RelativePath::parse("./a/../b").is_ok());
173        assert_eq!(RelativePath::parse("./a/../../b"), Err(RelativePathError::EscapesRoot));
174        assert_eq!(RelativePath::parse("./.."), Err(RelativePathError::EscapesRoot));
175    }
176
177    #[test]
178    fn resolve_joins_lexically() {
179        let p = RelativePath::parse("./data/./cache").unwrap();
180        assert_eq!(
181            p.resolve(std::path::Path::new("/root")),
182            std::path::PathBuf::from("/root/data/cache")
183        );
184    }
185}