vtcode_agent_plugins/
expansion.rs1use std::path::{Path, PathBuf};
2
3pub fn expand_placeholders(value: &str, plugin_root: &Path, plugin_data: &Path) -> String {
11 let root = plugin_root.display().to_string();
12 let data = plugin_data.display().to_string();
13 let mut out = String::with_capacity(value.len() + root.len());
14 let mut idx = 0;
15 while idx < value.len() {
16 let Some(rest) = value.get(idx..) else {
17 break;
18 };
19 if rest.starts_with("${PLUGIN_ROOT}") {
20 out.push_str(&root);
21 idx += "${PLUGIN_ROOT}".len();
22 } else if rest.starts_with("${PLUGIN_DATA}") {
23 out.push_str(&data);
24 idx += "${PLUGIN_DATA}".len();
25 } else {
26 let ch = rest.chars().next().unwrap_or_default();
28 out.push(ch);
29 idx += ch.len_utf8();
30 }
31 }
32 out
33}
34
35pub fn is_reserved_env_key(key: &str) -> bool {
39 key == "PLUGIN_ROOT" || key == "PLUGIN_DATA"
43}
44
45const SHELL_CONTROL_CHARS: &[char] = &[';', '|', '&', '>', '<', '`', '\n', '\r', '\0'];
50
51fn contains_shell_control(value: &str) -> bool {
52 value.chars().any(|c| SHELL_CONTROL_CHARS.contains(&c))
53}
54
55pub fn validate_command_token(command: &str) -> Result<(), String> {
64 if command.is_empty() {
65 return Err("command must not be empty".into());
66 }
67 if command == "./" || command == "./." {
68 return Err("command must name an executable, not a directory".into());
69 }
70 if contains_shell_control(command) {
71 return Err("command must be a single executable token, not a shell string".into());
72 }
73 if command.starts_with("./") {
74 Ok(())
75 } else {
76 if command.contains('/') || command.contains('\\') {
77 return Err("command with a path separator must start with ./".into());
78 }
79 if command.chars().any(|c| c.is_whitespace()) {
80 return Err("command must be a single executable token, not a shell string".into());
81 }
82 Ok(())
83 }
84}
85
86pub fn validate_cwd_form(cwd: &str) -> Result<(), String> {
92 if cwd.starts_with("./")
93 || cwd == "${PLUGIN_ROOT}"
94 || cwd.starts_with("${PLUGIN_ROOT}/")
95 || cwd == "${PLUGIN_DATA}"
96 || cwd.starts_with("${PLUGIN_DATA}/")
97 {
98 Ok(())
99 } else {
100 Err(format!("cwd has unsupported form: {cwd}"))
101 }
102}
103
104pub fn default_plugin_data_dir(plugin_name: &str) -> PathBuf {
113 if let Ok(paths) = vtcode_commons::VtCodePaths::resolve() {
114 return paths.data_dir().join("plugin-data").join(plugin_name);
115 }
116 if let Some(data_dir) = dirs::data_dir() {
117 return data_dir.join("vtcode").join("plugin-data").join(plugin_name);
118 }
119 std::env::temp_dir().join("vtcode-plugin-data").join(plugin_name)
120}
121
122pub fn resolve_cwd(cwd: Option<&str>, plugin_root: &Path, plugin_data: &Path) -> Result<PathBuf, crate::PluginError> {
130 let canonical_root = vtcode_commons::canonicalize(plugin_root)
131 .map_err(|e| crate::PluginError::PathEscape(format!("plugin root could not be resolved: {e}")))?;
132 let canonical_data = canonicalize_allow_missing(plugin_data).unwrap_or_else(|_| plugin_data.to_path_buf());
135
136 let Some(raw) = cwd else {
137 return Ok(canonical_root);
138 };
139 validate_cwd_form(raw).map_err(crate::PluginError::PathEscape)?;
140
141 let expanded = expand_placeholders(raw, plugin_root, plugin_data);
142 if raw.starts_with("./") {
143 if expanded.starts_with("./") {
147 return validate_plugin_relative(&expanded, plugin_root);
148 }
149 let candidate = PathBuf::from(&expanded);
150 let resolved = ensure_within(&canonical_root, &candidate)
151 .map_err(|e| crate::PluginError::PathEscape(format!("cwd escapes plugin root: {e}")))?;
152 return Ok(resolved);
153 }
154 if raw == "${PLUGIN_ROOT}" || raw.starts_with("${PLUGIN_ROOT}/") {
155 let candidate = PathBuf::from(&expanded);
156 let resolved = ensure_within(&canonical_root, &candidate)
157 .map_err(|e| crate::PluginError::PathEscape(format!("cwd escapes plugin root: {e}")))?;
158 return Ok(resolved);
159 }
160 let candidate = PathBuf::from(&expanded);
162 let resolved = ensure_within(&canonical_data, &candidate)
163 .map_err(|e| crate::PluginError::PathEscape(format!("cwd escapes plugin data dir: {e}")))?;
164 Ok(resolved)
165}
166
167pub(crate) fn canonicalize_allow_missing(path: &Path) -> std::io::Result<PathBuf> {
168 if let Ok(canonical) = vtcode_commons::canonicalize(path) {
169 return Ok(canonical);
170 }
171 let mut probe = path;
175 let mut suffix: Vec<std::ffi::OsString> = Vec::new();
176 loop {
177 if let Ok(existing) = vtcode_commons::canonicalize(probe) {
178 for part in suffix.iter().rev() {
179 if *part == ".." {
180 return Err(std::io::Error::new(
181 std::io::ErrorKind::InvalidInput,
182 format!("path escapes its root: {}", path.display()),
183 ));
184 }
185 }
186 let mut resolved = existing;
187 for part in suffix.iter().rev() {
188 resolved.push(part);
189 }
190 return Ok(resolved);
191 }
192 let Some(name) = probe.file_name() else {
193 return Err(std::io::Error::new(
194 std::io::ErrorKind::NotFound,
195 format!("path could not be resolved: {}", path.display()),
196 ));
197 };
198 suffix.push(name.to_os_string());
199 let Some(parent) = probe.parent() else {
200 return Err(std::io::Error::new(
201 std::io::ErrorKind::NotFound,
202 format!("path could not be resolved: {}", path.display()),
203 ));
204 };
205 probe = parent;
206 }
207}
208
209pub(crate) fn ensure_within(canonical_root: &Path, candidate: &Path) -> Result<PathBuf, String> {
210 let resolved = canonicalize_allow_missing(candidate).map_err(|e| format!("{} ({e})", candidate.display()))?;
211 if resolved.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
214 return Err(format!("{} escapes {}", candidate.display(), canonical_root.display()));
215 }
216 if resolved.starts_with(canonical_root) {
217 Ok(resolved)
218 } else {
219 Err(format!("{} escapes {}", candidate.display(), canonical_root.display()))
220 }
221}
222
223pub fn validate_plugin_relative(value: &str, root: &Path) -> Result<PathBuf, crate::PluginError> {
235 if !value.starts_with("./") {
236 return Err(crate::PluginError::PathEscape(format!("path must start with ./: {}", value)));
237 }
238
239 let canonical_root = vtcode_commons::canonicalize(root)
240 .map_err(|e| crate::PluginError::PathEscape(format!("plugin root could not be resolved: {e}")))?;
241
242 let candidate = root.join(value.get(2..).unwrap_or_default());
243
244 let lexical_ok = candidate
246 .components()
247 .all(|component| !matches!(component, std::path::Component::ParentDir));
248 if !lexical_ok {
249 return Err(crate::PluginError::PathEscape(format!("path escapes plugin root: {}", value)));
250 }
251
252 let mut probe = candidate.as_path();
255 let mut suffix: Vec<PathBuf> = Vec::new();
256 loop {
257 match vtcode_commons::canonicalize(probe) {
258 Ok(existing) => {
259 let mut resolved = existing;
260 for part in suffix.iter().rev() {
261 resolved.push(part);
262 }
263 if !resolved.starts_with(&canonical_root) {
264 return Err(crate::PluginError::PathEscape(format!("path escapes plugin root: {}", value)));
265 }
266 return Ok(resolved);
267 }
268 Err(_) => {
269 let Some(name) = probe.file_name() else {
270 return Err(crate::PluginError::PathEscape(format!("path could not be resolved: {}", value)));
271 };
272 suffix.push(PathBuf::from(name));
273 let Some(parent) = probe.parent() else {
274 return Err(crate::PluginError::PathEscape(format!("path could not be resolved: {}", value)));
275 };
276 probe = parent;
277 }
278 }
279 }
280}