Normalize workflow paths before case folding

validate_workflow_source_paths folded case before applying NFC, but
case folding is not closed under canonical equivalence: a decomposed
sequence and its precomposed form can fold to different strings. Two
supplied paths that a normalization-insensitive filesystem treats as
one entry therefore passed the collision check, and staging silently
overwrote one file with the other.

Apply NFC first, then fold, then normalize again, and add the Greek
pair that reproduced the gap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-09-11 15:23:32 -06:00
parent 111e737d53
commit b998f88cde

View file

@ -169,7 +169,16 @@ pub fn validate_workflow_source_paths<'a>(
if text.is_ascii() {
Cow::Owned(text.to_ascii_lowercase())
} else {
Cow::Owned(UniCase::unicode(text).to_folded_case().nfc().collect())
// Normalize before folding: case folding is not closed under
// canonical equivalence, so folding a decomposed sequence and
// folding its precomposed form can yield different strings.
let normalized: String = text.nfc().collect();
Cow::Owned(
UniCase::unicode(normalized)
.to_folded_case()
.nfc()
.collect(),
)
}
})
}
@ -561,6 +570,9 @@ mod source_path_tests {
["ΟΣ", "οσ/b"],
["é", "e\u{301}/b"],
["Straße", "STRASSE/b"],
// Canonically equivalent, but folding before normalizing yields
// different keys (U+03B1 U+03AF vs U+03AC U+03B9).
["α\u{345}\u{301}.md", "\u{1FB4}.md"],
] {
let paths = pair.map(|path| WorkflowPath::new(path).unwrap());
assert!(validate_workflow_source_paths(paths.iter()).is_err());