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`] backs
7//! `ServerId::new`/`ToolName::new`'s own baseline path-segment invariant; the stricter,
8//! filesystem-safe-*slug* rule both crates' `server_id` confinement checks enforce lives in
9//! [`crate::validate_server_id_slug`] instead (see its own doc comment for why).
10
11use std::path::{Component, Path};
12
13/// Sanitizes a file path for inclusion in an error message, to prevent information disclosure.
14///
15/// Replaces the home directory with `~` to avoid leaking usernames and full filesystem paths
16/// in error messages returned to callers (e.g. over the MCP protocol). Note that the rebuilt
17/// suffix is composed from normalized path components, so incidental input artifacts such as
18/// repeated separators or `.` segments are not preserved verbatim.
19///
20/// The comparison walks path components rather than matching raw strings, so a `/`-separated
21/// input matches a backslash-separated home directory (and vice versa) on platforms where both
22/// separators are valid. On Windows and macOS, components are also compared
23/// Unicode-case-insensitively (via [`str::to_lowercase`]) and Unicode-normalization-insensitively
24/// (via NFC normalization), matching those platforms' case-insensitive-but-case-preserving
25/// filesystem semantics and the fact that the same visible username can arrive pre-composed
26/// (NFC, e.g. `"Jos\u{e9}"`) from one source and decomposed (NFD, e.g. `"Jose\u{301}"`) from
27/// another; elsewhere the comparison stays case-sensitive and normalization-sensitive.
28///
29/// When `path` does not begin with `home` — e.g. it reaches this function through a different
30/// mount point, or (on Windows) as a `\\?\`-verbatim canonicalized path whose prefix shape the
31/// component walk does not recognize as equivalent to `home`'s — this falls back to scrubbing
32/// the bare username (`home`'s final component) wherever it appears in the path, so the
33/// username itself is never disclosed verbatim even when the fuller `~`-collapse of the whole
34/// home directory isn't achieved. On Windows/macOS, that fallback also returns the *whole* path
35/// NFC-normalized (not just the redacted span), since the underlying comparison it uses
36/// normalizes `path` up front — a segment outside the redacted username that happens to be
37/// NFD-spelled comes back precomposed. This is harmless for display, but on Windows, where NTFS
38/// treats an NFC- and an NFD-spelled filename as different files on disk, the rendered path is
39/// not guaranteed to name a file that literally exists under that exact spelling.
40///
41/// # Examples
42///
43/// ```
44/// use mcp_execution_core::sanitize_path_for_error;
45/// use std::path::Path;
46///
47/// // A path outside the home directory is left unchanged.
48/// assert_eq!(sanitize_path_for_error(Path::new("/tmp/x")), "/tmp/x");
49///
50/// // A path under the home directory has it redacted to `~`.
51/// let home = dirs::home_dir().expect("home dir available in this environment");
52/// let under_home = home.join("secret-file.md");
53/// assert_eq!(
54///     sanitize_path_for_error(&under_home),
55///     format!("~{}secret-file.md", std::path::MAIN_SEPARATOR),
56/// );
57/// ```
58#[must_use]
59pub fn sanitize_path_for_error(path: &Path) -> String {
60    dirs::home_dir().map_or_else(
61        || path.display().to_string(),
62        |home| strip_home_prefix(path, &home).unwrap_or_else(|| scrub_username(path, &home)),
63    )
64}
65
66/// Returns `path` with its leading `home` components replaced by `~`, or `None` if `path` is
67/// not rooted at `home`.
68fn strip_home_prefix(path: &Path, home: &Path) -> Option<String> {
69    let mut path_components = path.components();
70    for home_component in home.components() {
71        if !components_match(home_component, path_components.next()?) {
72            return None;
73        }
74    }
75    let mut result = String::from("~");
76    for component in path_components {
77        result.push(std::path::MAIN_SEPARATOR);
78        result.push_str(&component.as_os_str().to_string_lossy());
79    }
80    Some(result)
81}
82
83/// Defense-in-depth redaction for when `path` is not rooted at `home` (see
84/// [`sanitize_path_for_error`]'s doc comment for when this triggers): scrubs `home`'s bare
85/// username wherever it textually appears in `path`, independent of path structure.
86///
87/// This scrub is a plain substring match, not scoped to a path component: on this fallback path
88/// only, a component that merely contains the username as a substring (e.g. `alice-website` when
89/// the username is `alice`) is partially mangled (`~-website`) rather than left alone.
90/// Over-redaction is the accepted safe-failure direction for an information-disclosure guard.
91fn scrub_username(path: &Path, home: &Path) -> String {
92    let path_str = path.display().to_string();
93    let Some(username) = home.file_name() else {
94        return path_str;
95    };
96    let username = username.to_string_lossy();
97    if username.is_empty() {
98        return path_str;
99    }
100    replace_case_aware(&path_str, &username, "~")
101}
102
103#[cfg(any(windows, target_os = "macos"))]
104fn components_match(home: Component<'_>, path: Component<'_>) -> bool {
105    // Unicode-aware case folding (not `eq_ignore_ascii_case`, which only folds ASCII bytes and
106    // misses non-ASCII usernames such as Cyrillic), plus NFC normalization so a component that
107    // differs from the other only by composition form (e.g. precomposed "é" vs. "e" + combining
108    // acute) still compares equal. This compares whole components, so there is no byte-offset
109    // slicing to keep valid across either transform, unlike `replace_case_aware` below.
110    normalize_and_fold(&home.as_os_str().to_string_lossy())
111        == normalize_and_fold(&path.as_os_str().to_string_lossy())
112}
113
114#[cfg(not(any(windows, target_os = "macos")))]
115fn components_match(home: Component<'_>, path: Component<'_>) -> bool {
116    home == path
117}
118
119/// NFC-normalizes, Unicode-case-folds, then NFC-normalizes `s` again, so two strings that differ
120/// only by composition form (NFC vs. NFD) or by case compare equal after this transform.
121///
122/// The trailing re-normalization is required, not cosmetic: `str::to_lowercase` can turn an
123/// already-NFC string back into a non-NFC one. For example, `"J\u{30C}"` (capital J + combining
124/// caron) has no precomposed uppercase form, so it is already NFC; lowering it maps `J` to `j`
125/// character-by-character and leaves the combining caron untouched, producing `"j\u{30C}"` —
126/// which is *not* NFC, because the precomposed lowercase `"\u{1F0}"` (LATIN SMALL LETTER J WITH
127/// CARON) exists. Without the second `nfc()` pass, that decomposed fold would compare unequal to
128/// an already-precomposed `"\u{1F0}"` on the other side, even though they render identically.
129#[cfg(any(windows, target_os = "macos"))]
130fn normalize_and_fold(s: &str) -> String {
131    use unicode_normalization::UnicodeNormalization;
132    s.nfc().collect::<String>().to_lowercase().nfc().collect()
133}
134
135#[cfg(any(windows, target_os = "macos"))]
136fn replace_case_aware(haystack: &str, needle: &str, replacement: &str) -> String {
137    use unicode_normalization::UnicodeNormalization;
138    // Case-folds each candidate window with whole-string `str::to_lowercase` (not a per-char
139    // fold), so this agrees with `components_match`'s folding on Unicode's context-sensitive
140    // rules — e.g. Greek final sigma: "ΣΑΣ".to_lowercase() == "σας", which a char-by-char fold
141    // would render "σασ" and so fail to match.
142    //
143    // `haystack` and `needle` are each NFC-normalized as a whole *before* windowing starts,
144    // rather than only inside each candidate window. This matters because the window is sized
145    // from `needle`'s char count: normalizing only inside the window (as an earlier version of
146    // this function did) left the *raw*, pre-normalization char counts of `needle` and of a
147    // decomposed (NFD) matching span in `haystack` mismatched — e.g. NFC needle `"Jos\u{e9}"`
148    // (4 raw chars) against an NFD-spelled `"Jose\u{301}"` span in `haystack` (5 raw chars) never
149    // lined up a window of the right size, so `scrub_username`'s fallback silently failed to
150    // redact the username (issue #416). Normalizing both operands whole, up front, resolves this
151    // for any composition-form mismatch between them, because the same composed form is reached
152    // by both sides before their lengths are ever compared.
153    //
154    // The window is still sized from `needle`'s (now-normalized) char count, so `haystack`'s own
155    // char boundaries — computed from this same normalized string via `char_indices` — are always
156    // valid slice points for the output. The comparison itself, though, runs on the *folded* form
157    // (`normalize_and_fold`, which case-folds and re-normalizes), whose char count can differ from
158    // the window's normalized-but-unfolded char count. So a residual, accepted limitation remains:
159    // a needle/haystack pair whose folded forms only line up at a different char count than their
160    // normalized forms is missed. This covers both the original case (Turkish "İ" folding to two
161    // chars, or German "ß" needle against a haystack spelled "ss") and a normalization-adjacent one
162    // introduced by `normalize_and_fold`'s own post-fold re-normalization: a needle like
163    // `"J\u{30C}an"` (whose first char folds-and-recomposes to the 1-char "\u{1F0}", shortening the
164    // folded form relative to the normalized one) is not matched — see
165    // `replace_case_aware_preserves_byte_offsets_when_fold_changes_length`.
166    if needle.is_empty() {
167        return haystack.to_owned();
168    }
169    let haystack: String = haystack.nfc().collect();
170    let needle: String = needle.nfc().collect();
171    let needle_len = needle.chars().count();
172    let needle_folded = normalize_and_fold(&needle);
173    let boundaries: Vec<usize> = haystack
174        .char_indices()
175        .map(|(i, _)| i)
176        .chain(std::iter::once(haystack.len()))
177        .collect();
178
179    let mut result = String::with_capacity(haystack.len());
180    let mut last_end = 0;
181    let mut i = 0;
182    while i + needle_len < boundaries.len() {
183        let start = boundaries[i];
184        let end = boundaries[i + needle_len];
185        if normalize_and_fold(&haystack[start..end]) == needle_folded {
186            result.push_str(&haystack[last_end..start]);
187            result.push_str(replacement);
188            last_end = end;
189            i += needle_len;
190        } else {
191            i += 1;
192        }
193    }
194    result.push_str(&haystack[last_end..]);
195    result
196}
197
198#[cfg(not(any(windows, target_os = "macos")))]
199fn replace_case_aware(haystack: &str, needle: &str, replacement: &str) -> String {
200    haystack.replace(needle, replacement)
201}
202
203/// Validates that `segment` is a single plain path component: non-empty, and with no `..`,
204/// path separator, or root/prefix component.
205///
206/// Intended for validating a caller-supplied identifier (e.g. `server_id`) that will be pushed
207/// onto a confined base directory: constructing a fresh `Component::Normal` from the raw
208/// string instead of using the one this function returns would defeat the check on an input
209/// like `"a/."`, where `Path::components()` normalizes away the trailing `.` and this function
210/// sees a single `Normal("a")`, but a fresh `Component::Normal(OsStr::new("a/."))` would still
211/// carry the embedded separator. Callers should push the returned [`Component`] itself.
212///
213/// Returns `None` (rather than an error) so each caller can report the failure in its own
214/// crate-specific error type with whatever context it has (e.g. which parameter was invalid).
215///
216/// # Examples
217///
218/// ```
219/// use mcp_execution_core::validate_path_segment;
220///
221/// assert!(validate_path_segment("my-server").is_some());
222/// assert!(validate_path_segment("").is_none());
223/// assert!(validate_path_segment("..").is_none());
224/// assert!(validate_path_segment("a/b").is_none());
225/// ```
226#[must_use]
227pub fn validate_path_segment(segment: &str) -> Option<Component<'_>> {
228    let mut components = Path::new(segment).components();
229    match (components.next(), components.next()) {
230        (Some(component @ Component::Normal(_)), None) => Some(component),
231        _ => None,
232    }
233}
234
235/// Returns the first `char` in `s` that is not UTS #39 `Identifier_Status=Allowed`, or `None`
236/// if every character is Allowed.
237///
238/// Backs [`crate::ServerId::new`]/[`crate::ToolName::new`]'s stricter, second-layer invariant —
239/// a layer *on top of* [`validate_path_segment`], not a replacement for it: this function says
240/// nothing about path separators, `..`, or root/prefix components, and `validate_path_segment`
241/// says nothing about Unicode identifier safety. Both checks apply together. The Allowed set
242/// (from the [`unicode_security`] crate's `GeneralSecurityProfile::identifier_allowed` tables,
243/// Unicode 16.0) excludes format/control characters, most bidi controls, invisible characters,
244/// and other code points UTS #39 flags as unsafe in identifiers — but it does **not** detect
245/// homoglyphs (e.g. Cyrillic "а" U+0430 renders identically to Latin "a" but is Allowed); callers
246/// needing that protection must add a separate, dedicated check.
247///
248/// # Examples
249///
250/// ```
251/// use mcp_execution_core::first_disallowed_identifier_char;
252///
253/// assert_eq!(first_disallowed_identifier_char("café_menu_日本語"), None);
254/// assert_eq!(first_disallowed_identifier_char("get_issue\u{200D}"), Some('\u{200D}'));
255/// ```
256#[must_use]
257pub fn first_disallowed_identifier_char(s: &str) -> Option<char> {
258    use unicode_security::GeneralSecurityProfile;
259    s.chars().find(|c| !c.identifier_allowed())
260}
261
262/// Returns `true` if `path` contains a `..` (parent-directory) component.
263///
264/// Shared by every crate that confines a caller-supplied path to a base directory
265/// (`mcp-execution-skill`'s `output_path`, `mcp-execution-server`'s `output_dir`,
266/// `mcp-execution-cli`'s skill commands), so the traversal check itself has one
267/// implementation rather than three copies that could silently drift apart.
268///
269/// # Examples
270///
271/// ```
272/// use mcp_execution_core::contains_parent_dir;
273/// use std::path::Path;
274///
275/// assert!(contains_parent_dir(Path::new("../secret")));
276/// assert!(contains_parent_dir(Path::new("a/../b")));
277/// assert!(!contains_parent_dir(Path::new("a/b")));
278/// ```
279#[must_use]
280pub fn contains_parent_dir(path: &Path) -> bool {
281    path.components().any(|c| matches!(c, Component::ParentDir))
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn validate_path_segment_accepts_plain_name() {
290        assert!(validate_path_segment("my-server").is_some());
291    }
292
293    #[test]
294    fn validate_path_segment_rejects_empty() {
295        assert!(validate_path_segment("").is_none());
296    }
297
298    #[test]
299    fn validate_path_segment_rejects_parent_traversal() {
300        assert!(validate_path_segment("../other").is_none());
301        assert!(validate_path_segment("..").is_none());
302    }
303
304    #[test]
305    fn validate_path_segment_rejects_path_separator() {
306        assert!(validate_path_segment("a/b").is_none());
307    }
308
309    #[test]
310    fn first_disallowed_identifier_char_accepts_plain_and_non_ascii() {
311        assert_eq!(first_disallowed_identifier_char("my-server"), None);
312        assert_eq!(first_disallowed_identifier_char("café_menu_日本語"), None);
313    }
314
315    #[test]
316    fn first_disallowed_identifier_char_rejects_zwj() {
317        assert_eq!(
318            first_disallowed_identifier_char("get_issue\u{200D}"),
319            Some('\u{200D}')
320        );
321    }
322
323    #[test]
324    fn first_disallowed_identifier_char_guard_leaves_validate_path_segment_unchanged() {
325        // `validate_path_segment` must stay a purely structural check, unaware of Unicode
326        // identifier safety — proves this function was added as a sibling, not folded into it.
327        assert!(validate_path_segment("my notes").is_some());
328        assert!(validate_path_segment("a\u{200D}b").is_some());
329    }
330
331    #[test]
332    fn contains_parent_dir_detects_traversal() {
333        // Bare `..`.
334        assert!(contains_parent_dir(Path::new("..")));
335        // Leading position.
336        assert!(contains_parent_dir(Path::new("../b")));
337        // Middle position.
338        assert!(contains_parent_dir(Path::new("a/../b")));
339        // Trailing position.
340        assert!(contains_parent_dir(Path::new("a/..")));
341        assert!(!contains_parent_dir(Path::new("a/b")));
342    }
343
344    #[test]
345    fn sanitize_path_for_error_redacts_home_directory() {
346        let home = dirs::home_dir().unwrap();
347        let under_home = home.join(".claude").join("skills");
348        assert_eq!(
349            sanitize_path_for_error(&under_home),
350            format!(
351                "~{}.claude{}skills",
352                std::path::MAIN_SEPARATOR,
353                std::path::MAIN_SEPARATOR
354            )
355        );
356    }
357
358    #[test]
359    fn sanitize_path_for_error_leaves_non_home_path_unchanged() {
360        assert_eq!(sanitize_path_for_error(Path::new("/tmp/x")), "/tmp/x");
361    }
362
363    // `Path::components()` only treats `/` as a separator alongside `\` on Windows, so this
364    // variation is only meaningful, and only exercised, on that platform.
365    #[cfg(windows)]
366    #[test]
367    fn sanitize_path_for_error_redacts_home_directory_with_forward_slashes() {
368        let home = dirs::home_dir().unwrap();
369        let home_str = home.display().to_string().replace('\\', "/");
370        let under_home = format!("{home_str}/secret-file.md");
371        assert_eq!(
372            sanitize_path_for_error(Path::new(&under_home)),
373            format!("~{}secret-file.md", std::path::MAIN_SEPARATOR),
374        );
375    }
376
377    // Windows and macOS both have case-insensitive-but-case-preserving default filesystems, so
378    // this is exercised on both.
379    #[cfg(any(windows, target_os = "macos"))]
380    #[test]
381    fn sanitize_path_for_error_redacts_home_directory_case_insensitively() {
382        let home = dirs::home_dir().unwrap();
383        let flipped_case: String = home
384            .display()
385            .to_string()
386            .chars()
387            .map(|c| {
388                if c.is_ascii_uppercase() {
389                    c.to_ascii_lowercase()
390                } else if c.is_ascii_lowercase() {
391                    c.to_ascii_uppercase()
392                } else {
393                    c
394                }
395            })
396            .collect();
397        let under_home = format!("{flipped_case}{}secret-file.md", std::path::MAIN_SEPARATOR);
398        assert_eq!(
399            sanitize_path_for_error(Path::new(&under_home)),
400            format!("~{}secret-file.md", std::path::MAIN_SEPARATOR),
401        );
402    }
403
404    // Regression test for a non-ASCII username case leak: `eq_ignore_ascii_case` only folds
405    // ASCII bytes, so a Cyrillic username differing only by case was never recognized as a
406    // match, silently defeating the case-insensitive redaction on Windows/macOS.
407    #[cfg(any(windows, target_os = "macos"))]
408    #[test]
409    fn components_match_is_unicode_case_insensitive() {
410        let home_path = Path::new("Аня");
411        let path_path = Path::new("аня");
412        let home = home_path.components().next().unwrap();
413        let path = path_path.components().next().unwrap();
414        assert!(components_match(home, path));
415    }
416
417    #[cfg(any(windows, target_os = "macos"))]
418    #[test]
419    fn replace_case_aware_matches_non_ascii_case_variants() {
420        assert_eq!(
421            replace_case_aware("Аня/secret.md", "аня", "~"),
422            "~/secret.md"
423        );
424    }
425
426    // `str::to_lowercase()` can change a character's encoded length: Turkish "İ" folds to two
427    // chars, "i" + a combining dot above (verified: `"İ".to_lowercase().chars().count()` is 2).
428    // A naive port of the old byte-offset/`match_indices` approach to `to_lowercase` would slice
429    // a lowered buffer using needle-derived byte offsets that no longer line up with the
430    // original string once folding expands a character. This test proves the windowed
431    // comparison — which only ever slices at `haystack`'s own char boundaries — matches and
432    // replaces correctly instead of panicking or corrupting output, even though the window's
433    // folded form is longer than its raw form.
434    //
435    // Known limitation, not exercised here: the window is sized to `needle`'s *normalized* char
436    // count, but the comparison itself runs on the further *folded* form, whose char count can
437    // differ from the normalized-but-unfolded one — so a needle/haystack pair whose folded forms
438    // only line up at a different char count than their normalized forms is missed. This covers
439    // German "ß" needle against a haystack spelled "ss" (1 char vs. 2) and a normalization-adjacent
440    // case introduced by `normalize_and_fold`'s own post-fold re-normalization, e.g. needle
441    // `"J\u{30C}an"` against haystack `"\u{1F0}an"` (4-char normalized needle vs. 3-char folded
442    // form). Not a regression: the pre-S1/S3 code missed this identical input too. Accepted per the
443    // original design: the fallback's over-redaction bias makes a missed match here a false
444    // negative, not an information leak on its own, since the primary
445    // `strip_home_prefix`/`components_match` path (whole-component comparison, not a windowed
446    // substring search) still catches the common case.
447    #[cfg(any(windows, target_os = "macos"))]
448    #[test]
449    fn replace_case_aware_preserves_byte_offsets_when_fold_changes_length() {
450        assert_eq!(replace_case_aware("aİb", "İ", "~"), "a~b");
451        // A plain ASCII "i" is not a case variant of "İ" under this fold, so no match — the
452        // differing fold length must not cause a panic or a false match.
453        assert_eq!(replace_case_aware("aİb", "i", "~"), "aİb");
454    }
455
456    // Regression test for #416: two components that render identically ("José") but differ in
457    // Unicode composition form — one precomposed (NFC: "e" + U+00E9 "é"), one decomposed (NFD:
458    // "e" + "e" + U+0301 combining acute accent) — must still be recognized as the same
459    // component once both sides are NFC-normalized before folding.
460    #[cfg(any(windows, target_os = "macos"))]
461    #[test]
462    fn components_match_is_unicode_normalization_insensitive() {
463        let home_path = Path::new("Jos\u{e9}");
464        let path_path = Path::new("Jose\u{301}");
465        let home = home_path.components().next().unwrap();
466        let path = path_path.components().next().unwrap();
467        assert!(components_match(home, path));
468    }
469
470    // Regression test for critic finding S3: "J\u{30C}" (capital J + combining caron) has no
471    // precomposed uppercase form, so it is already NFC; its lowercase, "\u{1F0}" (LATIN SMALL
472    // LETTER J WITH CARON), *does* have a precomposed form. A fold that does not re-normalize
473    // after lowering emits "j" + combining caron (not NFC) for the first operand while the second
474    // stays precomposed, comparing unequal even though both render as "ǰ". `replace_case_aware`
475    // does not have a matching test for this exact pair: the same fold-driven shortening it fixes
476    // here also *shrinks* the folded form relative to the window's normalized size there, which is
477    // exactly the residual limitation documented on
478    // `replace_case_aware_preserves_byte_offsets_when_fold_changes_length` above.
479    #[cfg(any(windows, target_os = "macos"))]
480    #[test]
481    fn components_match_handles_fold_without_precomposed_uppercase() {
482        let home_path = Path::new("J\u{30C}");
483        let path_path = Path::new("\u{1F0}");
484        let home = home_path.components().next().unwrap();
485        let path = path_path.components().next().unwrap();
486        assert!(components_match(home, path));
487    }
488
489    // Regression test for critic finding S1: prior to NFC-normalizing `haystack` and `needle` as
490    // whole strings before windowing, a window sized from the *raw* (already-NFC) needle's char
491    // count did not line up with a longer, NFD-decomposed matching span in `haystack`, so
492    // `scrub_username`'s fallback path left the username visible verbatim in the redacted output.
493    #[cfg(any(windows, target_os = "macos"))]
494    #[test]
495    fn replace_case_aware_matches_nfc_needle_against_nfd_haystack_span() {
496        let needle = "Jos\u{e9}"; // NFC, 4 raw chars
497        let haystack = "/Volumes/Data/Users/Jose\u{301}/notes.md"; // NFD "Jose" + combining acute
498        assert_eq!(
499            replace_case_aware(haystack, needle, "~"),
500            "/Volumes/Data/Users/~/notes.md"
501        );
502    }
503
504    // Reverse direction of the same #416/S1 gap: an NFD-decomposed needle against an
505    // NFC-precomposed haystack span.
506    #[cfg(any(windows, target_os = "macos"))]
507    #[test]
508    fn replace_case_aware_matches_nfd_needle_against_nfc_haystack_span() {
509        let needle = "Jose\u{301}"; // NFD, 5 raw chars
510        let haystack = "/Volumes/Data/Users/Jos\u{e9}/notes.md"; // NFC "José"
511        assert_eq!(
512            replace_case_aware(haystack, needle, "~"),
513            "/Volumes/Data/Users/~/notes.md"
514        );
515    }
516
517    // Regression test for critic finding S2: the previous version of this test used OHM SIGN
518    // (U+2126), which `str::to_lowercase` alone already folds to the same value as GREEK CAPITAL
519    // LETTER OMEGA (U+03A9) — so it passed even without any normalization fix and proved nothing
520    // about normalization specifically. U+0387 GREEK ANO TELEIA NFC-normalizes to U+00B7 MIDDLE
521    // DOT (a canonical singleton mapping); neither character has a case, so `to_lowercase` alone
522    // cannot merge them — only NFC normalization does, making this a genuine test of the
523    // normalization path. Note this does *not* exercise `normalize_and_fold`'s post-fold
524    // re-normalization (S3): both operands are caseless, so folding is a no-op here — see
525    // `components_match_handles_fold_without_precomposed_uppercase` above for that case instead.
526    #[cfg(any(windows, target_os = "macos"))]
527    #[test]
528    fn replace_case_aware_matches_caseless_singleton_normalization() {
529        assert_eq!(replace_case_aware("a \u{387} b", "\u{b7}", "~"), "a ~ b");
530    }
531
532    #[cfg(any(windows, target_os = "macos"))]
533    #[test]
534    fn replace_case_aware_replaces_multiple_occurrences() {
535        assert_eq!(
536            replace_case_aware("Alice/Alice/notes.md", "alice", "~"),
537            "~/~/notes.md"
538        );
539    }
540
541    // Regression test for the Greek final-sigma inconsistency the critic caught: whole-string
542    // `str::to_lowercase()` applies Unicode's context-sensitive rule ("ΣΑΣ".to_lowercase() ==
543    // "σας", using final sigma "ς"), which a naive per-char fold (`char::to_lowercase` on each
544    // char independently) would render "σασ" — disagreeing with `components_match`, which folds
545    // the same way `replace_case_aware` does here. Proves both functions now agree.
546    #[cfg(any(windows, target_os = "macos"))]
547    #[test]
548    fn replace_case_aware_matches_greek_final_sigma_case_variant() {
549        assert_eq!(
550            replace_case_aware("/home/ΣΑΣ/secret.md", "σας", "~"),
551            "/home/~/secret.md"
552        );
553        assert_eq!(
554            replace_case_aware("/home/σας/secret.md", "ΣΑΣ", "~"),
555            "/home/~/secret.md"
556        );
557    }
558
559    /// Reproduces the mounted/bind-mount scenario from the critic review: `home` appears as a
560    /// non-leading substring (e.g. under `/mnt/snapshot`), so the leading-prefix component walk
561    /// in `strip_home_prefix` cannot match it. Verifies the `scrub_username` fallback still
562    /// keeps the username out of the rendered output.
563    #[test]
564    fn sanitize_path_for_error_scrubs_username_when_home_is_not_a_leading_prefix() {
565        let home = dirs::home_dir().unwrap();
566        let username = home.file_name().unwrap().to_string_lossy().into_owned();
567
568        let mut mounted = std::path::PathBuf::from("mnt");
569        mounted.push("snapshot");
570        for component in home
571            .components()
572            .filter(|c| matches!(c, Component::Normal(_)))
573        {
574            mounted.push(component.as_os_str());
575        }
576        mounted.push("secret.md");
577
578        let sanitized = sanitize_path_for_error(&mounted);
579        assert!(!sanitized.to_lowercase().contains(&username.to_lowercase()));
580        assert!(sanitized.contains('~'));
581    }
582
583    /// Reproduces the Windows `\\?\`-verbatim canonicalized-path regression from the critic
584    /// review: `std::fs::canonicalize` prefixes the drive with `\\?\`, which
585    /// `strip_home_prefix`'s component walk does not recognize as equivalent to a plain `C:\`
586    /// prefix. Verifies the `scrub_username` fallback still keeps the username out of the
587    /// rendered output.
588    #[cfg(windows)]
589    #[test]
590    fn sanitize_path_for_error_scrubs_username_from_canonicalized_home_path() {
591        let home = dirs::home_dir().unwrap();
592        let username = home.file_name().unwrap().to_string_lossy().into_owned();
593        let canonical = std::fs::canonicalize(&home).unwrap();
594
595        let sanitized = sanitize_path_for_error(&canonical);
596        assert!(!sanitized.to_lowercase().contains(&username.to_lowercase()));
597    }
598}