From b998f88cde8412e006c93580035a0deb5393e7ff Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Fri, 11 Sep 2026 15:23:32 -0600 Subject: [PATCH] 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 --- lib/foundation/fabro-types/src/workflow_version.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/foundation/fabro-types/src/workflow_version.rs b/lib/foundation/fabro-types/src/workflow_version.rs index 8b448eaee..555be2814 100644 --- a/lib/foundation/fabro-types/src/workflow_version.rs +++ b/lib/foundation/fabro-types/src/workflow_version.rs @@ -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());