1use std::fmt;
15
16#[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 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38
39 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
73pub struct RelativePath(String);
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum RelativePathError {
78 MissingDotSlash,
80 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 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 pub fn as_str(&self) -> &str {
109 &self.0
110 }
111
112 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
140pub(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}