mcp_execution_core/path.rs
1//! Path sanitization and validation shared by path-confinement checks across the workspace.
2//!
3//! Confinement checks in `mcp-execution-skill` (`save_skill`'s `output_path`) and
4//! `mcp-execution-server` (`introspect_server`'s `output_dir`) report the offending path back
5//! to the caller. [`sanitize_path_for_error`] is the one place that redaction happens, so both
6//! crates report errors with the same privacy guarantee. [`validate_path_segment`] is the one
7//! place both crates validate a caller-supplied `server_id` as a single plain path component,
8//! so a `..` or path-separator smuggled into it is rejected identically by both.
9
10use std::path::{Component, Path};
11
12/// Sanitizes a file path for inclusion in an error message, to prevent information disclosure.
13///
14/// Replaces the home directory with `~` to avoid leaking usernames and full filesystem paths
15/// in error messages returned to callers (e.g. over the MCP protocol). Note that the rebuilt
16/// suffix is composed from normalized path components, so incidental input artifacts such as
17/// repeated separators or `.` segments are not preserved verbatim.
18///
19/// The comparison walks path components rather than matching raw strings, so a `/`-separated
20/// input matches a backslash-separated home directory (and vice versa) on platforms where both
21/// separators are valid. On Windows and macOS, components are also compared
22/// ASCII-case-insensitively, matching those platforms' case-insensitive-but-case-preserving
23/// filesystem semantics; elsewhere the comparison stays case-sensitive.
24///
25/// When `path` does not begin with `home` — e.g. it reaches this function through a different
26/// mount point, or (on Windows) as a `\\?\`-verbatim canonicalized path whose prefix shape the
27/// component walk does not recognize as equivalent to `home`'s — this falls back to scrubbing
28/// the bare username (`home`'s final component) wherever it appears in the path, so the
29/// username itself is never disclosed verbatim even when the fuller `~`-collapse of the whole
30/// home directory isn't achieved.
31///
32/// # Examples
33///
34/// ```
35/// use mcp_execution_core::sanitize_path_for_error;
36/// use std::path::Path;
37///
38/// // A path outside the home directory is left unchanged.
39/// assert_eq!(sanitize_path_for_error(Path::new("/tmp/x")), "/tmp/x");
40///
41/// // A path under the home directory has it redacted to `~`.
42/// let home = dirs::home_dir().expect("home dir available in this environment");
43/// let under_home = home.join("secret-file.md");
44/// assert_eq!(
45/// sanitize_path_for_error(&under_home),
46/// format!("~{}secret-file.md", std::path::MAIN_SEPARATOR),
47/// );
48/// ```
49#[must_use]
50pub fn sanitize_path_for_error(path: &Path) -> String {
51 dirs::home_dir().map_or_else(
52 || path.display().to_string(),
53 |home| strip_home_prefix(path, &home).unwrap_or_else(|| scrub_username(path, &home)),
54 )
55}
56
57/// Returns `path` with its leading `home` components replaced by `~`, or `None` if `path` is
58/// not rooted at `home`.
59fn strip_home_prefix(path: &Path, home: &Path) -> Option<String> {
60 let mut path_components = path.components();
61 for home_component in home.components() {
62 if !components_match(home_component, path_components.next()?) {
63 return None;
64 }
65 }
66 let mut result = String::from("~");
67 for component in path_components {
68 result.push(std::path::MAIN_SEPARATOR);
69 result.push_str(&component.as_os_str().to_string_lossy());
70 }
71 Some(result)
72}
73
74/// Defense-in-depth redaction for when `path` is not rooted at `home` (see
75/// [`sanitize_path_for_error`]'s doc comment for when this triggers): scrubs `home`'s bare
76/// username wherever it textually appears in `path`, independent of path structure.
77///
78/// This scrub is a plain substring match, not scoped to a path component: on this fallback path
79/// only, a component that merely contains the username as a substring (e.g. `alice-website` when
80/// the username is `alice`) is partially mangled (`~-website`) rather than left alone.
81/// Over-redaction is the accepted safe-failure direction for an information-disclosure guard.
82fn scrub_username(path: &Path, home: &Path) -> String {
83 let path_str = path.display().to_string();
84 let Some(username) = home.file_name() else {
85 return path_str;
86 };
87 let username = username.to_string_lossy();
88 if username.is_empty() {
89 return path_str;
90 }
91 replace_case_aware(&path_str, &username, "~")
92}
93
94#[cfg(any(windows, target_os = "macos"))]
95fn components_match(home: Component<'_>, path: Component<'_>) -> bool {
96 // TODO(critic): ASCII-only case folding misses non-ASCII usernames (e.g. Cyrillic); the
97 // scrub_username fallback in sanitize_path_for_error covers that gap.
98 home.as_os_str()
99 .to_string_lossy()
100 .eq_ignore_ascii_case(&path.as_os_str().to_string_lossy())
101}
102
103#[cfg(not(any(windows, target_os = "macos")))]
104fn components_match(home: Component<'_>, path: Component<'_>) -> bool {
105 home == path
106}
107
108#[cfg(any(windows, target_os = "macos"))]
109fn replace_case_aware(haystack: &str, needle: &str, replacement: &str) -> String {
110 // `to_ascii_lowercase` only remaps bytes in the ASCII range and never changes a string's
111 // byte length, so every byte offset found in the lowered copies below is also a valid slice
112 // point into the original `haystack`/`needle`. This invariant breaks if the case-folding
113 // strategy is ever changed to something Unicode-aware (e.g. `to_lowercase`).
114 let haystack_lower = haystack.to_ascii_lowercase();
115 let needle_lower = needle.to_ascii_lowercase();
116 let mut result = String::with_capacity(haystack.len());
117 let mut last_end = 0;
118 for (start, _) in haystack_lower.match_indices(needle_lower.as_str()) {
119 result.push_str(&haystack[last_end..start]);
120 result.push_str(replacement);
121 last_end = start + needle.len();
122 }
123 result.push_str(&haystack[last_end..]);
124 result
125}
126
127#[cfg(not(any(windows, target_os = "macos")))]
128fn replace_case_aware(haystack: &str, needle: &str, replacement: &str) -> String {
129 haystack.replace(needle, replacement)
130}
131
132/// Validates that `segment` is a single plain path component: non-empty, and with no `..`,
133/// path separator, or root/prefix component.
134///
135/// Intended for validating a caller-supplied identifier (e.g. `server_id`) that will be pushed
136/// onto a confined base directory: constructing a fresh `Component::Normal` from the raw
137/// string instead of using the one this function returns would defeat the check on an input
138/// like `"a/."`, where `Path::components()` normalizes away the trailing `.` and this function
139/// sees a single `Normal("a")`, but a fresh `Component::Normal(OsStr::new("a/."))` would still
140/// carry the embedded separator. Callers should push the returned [`Component`] itself.
141///
142/// Returns `None` (rather than an error) so each caller can report the failure in its own
143/// crate-specific error type with whatever context it has (e.g. which parameter was invalid).
144///
145/// # Examples
146///
147/// ```
148/// use mcp_execution_core::validate_path_segment;
149///
150/// assert!(validate_path_segment("my-server").is_some());
151/// assert!(validate_path_segment("").is_none());
152/// assert!(validate_path_segment("..").is_none());
153/// assert!(validate_path_segment("a/b").is_none());
154/// ```
155#[must_use]
156pub fn validate_path_segment(segment: &str) -> Option<Component<'_>> {
157 let mut components = Path::new(segment).components();
158 match (components.next(), components.next()) {
159 (Some(component @ Component::Normal(_)), None) => Some(component),
160 _ => None,
161 }
162}
163
164/// Returns `true` if `path` contains a `..` (parent-directory) component.
165///
166/// Shared by every crate that confines a caller-supplied path to a base directory
167/// (`mcp-execution-skill`'s `output_path`, `mcp-execution-server`'s `output_dir`,
168/// `mcp-execution-cli`'s skill commands), so the traversal check itself has one
169/// implementation rather than three copies that could silently drift apart.
170///
171/// # Examples
172///
173/// ```
174/// use mcp_execution_core::contains_parent_dir;
175/// use std::path::Path;
176///
177/// assert!(contains_parent_dir(Path::new("../secret")));
178/// assert!(contains_parent_dir(Path::new("a/../b")));
179/// assert!(!contains_parent_dir(Path::new("a/b")));
180/// ```
181#[must_use]
182pub fn contains_parent_dir(path: &Path) -> bool {
183 path.components().any(|c| matches!(c, Component::ParentDir))
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn validate_path_segment_accepts_plain_name() {
192 assert!(validate_path_segment("my-server").is_some());
193 }
194
195 #[test]
196 fn validate_path_segment_rejects_empty() {
197 assert!(validate_path_segment("").is_none());
198 }
199
200 #[test]
201 fn validate_path_segment_rejects_parent_traversal() {
202 assert!(validate_path_segment("../other").is_none());
203 assert!(validate_path_segment("..").is_none());
204 }
205
206 #[test]
207 fn validate_path_segment_rejects_path_separator() {
208 assert!(validate_path_segment("a/b").is_none());
209 }
210
211 #[test]
212 fn contains_parent_dir_detects_traversal() {
213 // Bare `..`.
214 assert!(contains_parent_dir(Path::new("..")));
215 // Leading position.
216 assert!(contains_parent_dir(Path::new("../b")));
217 // Middle position.
218 assert!(contains_parent_dir(Path::new("a/../b")));
219 // Trailing position.
220 assert!(contains_parent_dir(Path::new("a/..")));
221 assert!(!contains_parent_dir(Path::new("a/b")));
222 }
223
224 #[test]
225 fn sanitize_path_for_error_redacts_home_directory() {
226 let home = dirs::home_dir().unwrap();
227 let under_home = home.join(".claude").join("skills");
228 assert_eq!(
229 sanitize_path_for_error(&under_home),
230 format!(
231 "~{}.claude{}skills",
232 std::path::MAIN_SEPARATOR,
233 std::path::MAIN_SEPARATOR
234 )
235 );
236 }
237
238 #[test]
239 fn sanitize_path_for_error_leaves_non_home_path_unchanged() {
240 assert_eq!(sanitize_path_for_error(Path::new("/tmp/x")), "/tmp/x");
241 }
242
243 // `Path::components()` only treats `/` as a separator alongside `\` on Windows, so this
244 // variation is only meaningful, and only exercised, on that platform.
245 #[cfg(windows)]
246 #[test]
247 fn sanitize_path_for_error_redacts_home_directory_with_forward_slashes() {
248 let home = dirs::home_dir().unwrap();
249 let home_str = home.display().to_string().replace('\\', "/");
250 let under_home = format!("{home_str}/secret-file.md");
251 assert_eq!(
252 sanitize_path_for_error(Path::new(&under_home)),
253 format!("~{}secret-file.md", std::path::MAIN_SEPARATOR),
254 );
255 }
256
257 // Windows and macOS both have case-insensitive-but-case-preserving default filesystems, so
258 // this is exercised on both.
259 #[cfg(any(windows, target_os = "macos"))]
260 #[test]
261 fn sanitize_path_for_error_redacts_home_directory_case_insensitively() {
262 let home = dirs::home_dir().unwrap();
263 let flipped_case: String = home
264 .display()
265 .to_string()
266 .chars()
267 .map(|c| {
268 if c.is_ascii_uppercase() {
269 c.to_ascii_lowercase()
270 } else if c.is_ascii_lowercase() {
271 c.to_ascii_uppercase()
272 } else {
273 c
274 }
275 })
276 .collect();
277 let under_home = format!("{flipped_case}{}secret-file.md", std::path::MAIN_SEPARATOR);
278 assert_eq!(
279 sanitize_path_for_error(Path::new(&under_home)),
280 format!("~{}secret-file.md", std::path::MAIN_SEPARATOR),
281 );
282 }
283
284 /// Reproduces the mounted/bind-mount scenario from the critic review: `home` appears as a
285 /// non-leading substring (e.g. under `/mnt/snapshot`), so the leading-prefix component walk
286 /// in `strip_home_prefix` cannot match it. Verifies the `scrub_username` fallback still
287 /// keeps the username out of the rendered output.
288 #[test]
289 fn sanitize_path_for_error_scrubs_username_when_home_is_not_a_leading_prefix() {
290 let home = dirs::home_dir().unwrap();
291 let username = home.file_name().unwrap().to_string_lossy().into_owned();
292
293 let mut mounted = std::path::PathBuf::from("mnt");
294 mounted.push("snapshot");
295 for component in home
296 .components()
297 .filter(|c| matches!(c, Component::Normal(_)))
298 {
299 mounted.push(component.as_os_str());
300 }
301 mounted.push("secret.md");
302
303 let sanitized = sanitize_path_for_error(&mounted);
304 assert!(!sanitized.to_lowercase().contains(&username.to_lowercase()));
305 assert!(sanitized.contains('~'));
306 }
307
308 /// Reproduces the Windows `\\?\`-verbatim canonicalized-path regression from the critic
309 /// review: `std::fs::canonicalize` prefixes the drive with `\\?\`, which
310 /// `strip_home_prefix`'s component walk does not recognize as equivalent to a plain `C:\`
311 /// prefix. Verifies the `scrub_username` fallback still keeps the username out of the
312 /// rendered output.
313 #[cfg(windows)]
314 #[test]
315 fn sanitize_path_for_error_scrubs_username_from_canonicalized_home_path() {
316 let home = dirs::home_dir().unwrap();
317 let username = home.file_name().unwrap().to_string_lossy().into_owned();
318 let canonical = std::fs::canonicalize(&home).unwrap();
319
320 let sanitized = sanitize_path_for_error(&canonical);
321 assert!(!sanitized.to_lowercase().contains(&username.to_lowercase()));
322 }
323}