Skip to main content

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}