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}