Repository: prefix-dev/rattler-build (rust)

Issue:
Add a warning for packages with mixed case
If a package has mix-cased filenames (e.g. `foo/BAR` and `foo/bar`) then the package does not work fine on case-insensitive filesystems (like the standard APFS on Apple).

We can detect this relatively easily at build time and issue a warning.


Pull request description:
feat: add Unicode normalization for path handling and detect case-insensitive filename collisions
closes #1262 hopefully, i am only confident with unit tests but hopefully it passes mac os!

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 4ffc01d5..a37f0aa4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4336,6 +4336,7 @@ dependencies = [
  "tracing-subscriber",
  "tracing-test",
  "tui-input",
+ "unicode-normalization",
  "url",
  "walkdir",
  "which",
diff --git a/Cargo.toml b/Cargo.toml
index e875f975..6785f587 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -176,6 +176,7 @@ lazy_static = "1.5.0"
 reqwest-retry = "0.7.0"
 retry-policies = "0.4.0"
 strum = "0.27.1"
+unicode-normalization = "0.1.24"
 
 [target.'cfg(not(target_os = "windows"))'.dependencies]
 sha2 = { version = "0.10.8", features = ["asm"] }
diff --git a/py-rattler-build/Cargo.lock b/py-rattler-build/Cargo.lock
index 0b57f7d2..2d74a7a6 100644
--- a/py-rattler-build/Cargo.lock
+++ b/py-rattler-build/Cargo.lock
@@ -4130,6 +4130,7 @@ dependencies = [
  "tracing",
  "tracing-core",
  "tracing-subscriber",
+ "unicode-normalization",
  "url",
  "walkdir",
  "which",
diff --git a/src/packaging.rs b/src/packaging.rs
index 13dcf209..47cff1ba 100644
--- a/src/packaging.rs
+++ b/src/packaging.rs
@@ -1,9 +1,9 @@
 //! This module contains the functions to package a conda package from a given
 //! output.
 use std::{
-    collections::HashSet,
+    collections::{HashMap, HashSet},
     io::Write,
-    path::{Component, Path, PathBuf},
+    path::{Component, MAIN_SEPARATOR, Path, PathBuf},
 };
 
 use fs_err as fs;
@@ -15,6 +15,7 @@ use rattler_conda_types::{
 use rattler_package_streaming::write::{
     CompressionLevel, write_conda_package, write_tar_bz2_package,
 };
+use unicode_normalization::UnicodeNormalization;
 
 mod file_finder;
 mod file_mapper;
@@ -231,6 +232,140 @@ impl rattler_package_streaming::write::ProgressBar for ProgressBar {
     }
 }
 
+/// Error type for path normalization operations
+#[derive(Debug, thiserror::Error)]
+pub enum PathNormalizationError {
+    /// Error when a path component contains invalid Unicode
+    #[error("Path component contains invalid Unicode: {0}")]
+    InvalidUnicode(String),
+}
+
+/// Normalizes a component string for comparison.
+///
+/// This helper function applies Unicode normalization (NFKC) and optional case folding to a path component.
+/// When case folding is applied, it's done in a way that properly handles special Unicode cases.
+fn normalize_component(component_str: &str, to_lowercase: bool) -> String {
+    if to_lowercase {
+        let normalized = component_str.nfkc().collect::<String>();
+        normalized.to_uppercase().to_lowercase()
+    } else {
+        component_str.nfkc().collect::<String>()
+    }
+}
+
+/// Normalizes a path for case-insensitive comparison.
+///
+/// This function:
+/// 1. Applies Unicode normalization (NFKC) to each path component
+/// 2. Handles path separators consistently across platforms
+/// 3. Optionally converts to lowercase for case-insensitive comparison
+///
+/// Returns a normalized string representation of the path.
+fn normalize_path_for_comparison(
+    path: &Path,
+    to_lowercase: bool,
+) -> Result<String, PathNormalizationError> {
+    let estimated_capacity = path.as_os_str().len() * 6 / 5 + path.components().count();
+    let mut normalized = String::with_capacity(estimated_capacity);
+    let mut first = true;
+
+    for c in path.components() {
+        match c {
+            Component::CurDir => continue,
+            Component::RootDir => {
+                normalized.push(MAIN_SEPARATOR);
+                first = false;
+            }
+            _ => {
+                if !first {
+                    normalized.push(MAIN_SEPARATOR);
+                }
+
+                let os_str = match c {
+                    Component::Prefix(p) => p.as_os_str(),
+                    _ => c.as_os_str(),
+                };
+
+                let component_type = if matches!(c, Component::Prefix(_)) {
+                    "Prefix"
+                } else {
+                    "Component"
+                };
+                let component_str = os_str.to_str().ok_or_else(|| {
+                    PathNormalizationError::InvalidUnicode(format!(
+                        "{}: {}",
+                        component_type,
+                        os_str.to_string_lossy()
+                    ))
+                })?;
+
+                normalized.push_str(&normalize_component(component_str, to_lowercase));
+            }
+        }
+    }
+
+    Ok(normalized)
+}
+
+/// Finds paths that would collide on case-insensitive filesystems.
+///
+/// Returns groups of paths that differ only by case.
+pub fn find_case_insensitive_collisions<I, P>(paths: I) -> Vec<Vec<PathBuf>>
+where
+    I: IntoIterator<Item = P>,
+    P: AsRef<Path>,
+{
+    let paths_vec: Vec<P> = paths.into_iter().collect();
+    let mut lc_map: HashMap<String, Vec<PathBuf>> = HashMap::with_capacity(paths_vec.len() / 2);
+
+    for path_ref in &paths_vec {
+        let path = path_ref.as_ref();
+        let case_folded = match normalize_path_for_comparison(path, true) {
+            Ok(normalized) => normalized,
+            Err(err) => {
+                tracing::warn!(
+                    "Failed to normalize path for comparison: {}: {}",
+                    path.display(),
+                    err
+                );
+                path.display().to_string().to_lowercase()
+            }
+        };
+
+        lc_map
+            .entry(case_folded)
+            .or_insert_with(|| Vec::with_capacity(1))
+            .push(path.to_path_buf());
+    }
+
+    let mut result: Vec<Vec<PathBuf>> = lc_map
+        .into_values()
+        .filter(|group| group.len() > 1)
+        .map(|group| {
+            let mut unique_vec: Vec<PathBuf> = if group.len() <= 4 {
+                let mut unique = Vec::with_capacity(group.len());
+                for path in group {
+                    if !unique.iter().any(|p| p == &path) {
+                        unique.push(path);
+                    }
+                }
+                unique
+            } else {
+                let mut unique_set = HashSet::with_capacity(group.len());
+                group.into_iter().for_each(|path| {
+                    unique_set.insert(path);
+                });
+                unique_set.into_iter().collect()
+            };
+            unique_vec.sort_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
+            unique_vec
+        })
+        .collect();
+
+    result.sort_by(|a, b| a[0].as_os_str().cmp(b[0].as_os_str()));
+    result
+}
+
 /// Given an output and a set of new files, create a conda package.
 /// This function will copy all the files to a temporary directory and then
 /// create a conda package from that. Note that the output needs to have its
@@ -309,6 +444,20 @@ pub fn package_conda(
         }
     });
 
+    for group in find_case_insensitive_collisions(&files) {
+        let list = group
+            .iter()
+            .map(|p| p.display().to_string())
+            .collect::<Vec<_>>()
+            .join(", ");
+        let warn_str = format!(
+            "Mixed-case filenames detected, case-insensitive filesystems may break: {}",
+            list
+        );
+        tracing::error!(warn_str);
+        output.record_warning(&warn_str);
+    }
+
     let normalize_path = |p: &Path| -> String { p.display().to_string().replace('\\', "/") };
 
     files.iter().for_each(|f| {
@@ -421,3 +570,45 @@ impl Output {
         package_conda(self, tool_configuration, &files_after)
     }
 }
+
+#[cfg(test)]
+mod packaging_tests {
+    use super::*;
+    use std::path::Path;
+
+    #[test]
+    fn test_find_case_insensitive_collisions_detects() {
+        let files = vec![
+            Path::new("foo/BAR"),
+            Path::new("foo/bar"),
+            Path::new("foo/Baz"),
+            Path::new("foo/qux"),
+        ];
+        let groups = find_case_insensitive_collisions(&files);
+        assert_eq!(groups.len(), 1);
+
+        let paths_in_group: Vec<String> =
+            groups[0].iter().map(|p| p.display().to_string()).collect();
+
+        assert!(paths_in_group.contains(&"foo/BAR".to_string()));
+        assert!(paths_in_group.contains(&"foo/bar".to_string()));
+    }
+
+    #[test]
+    fn test_find_case_insensitive_collisions_empty() {
+        let files = vec![Path::new("foo/bar"), Path::new("foo/baz")];
+        let groups = find_case_insensitive_collisions(&files);
+        assert!(groups.is_empty());
+    }
+
+    #[test]
+    fn test_find_case_insensitive_collisions_unicode() {
+        let files = vec![
+            Path::new("foo/straße"),
+            Path::new("foo/STRASSE"),
+            Path::new("foo/other"),
+        ];
+        let groups = find_case_insensitive_collisions(&files);
+        assert_eq!(groups.len(), 1);
+    }
+}


Test patch:
diff --git a/test/end-to-end/test_simple.py b/test/end-to-end/test_simple.py
index 411ffb07..df9247f5 100644
--- a/test/end-to-end/test_simple.py
+++ b/test/end-to-end/test_simple.py
@@ -1380,6 +1380,68 @@ def test_abi3(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
     assert index["platform"] == host_subdir().split("-")[0]
 
 
+@pytest.mark.skipif(
+    os.name == "nt" or platform.system() == "Darwin",
+    reason="Filesystem case-insensitivity prevents testing collision warning trigger",
+)
+def test_case_insensitive_collision_warning(
+    rattler_build: RattlerBuild, tmp_path: Path
+):
+    recipe_content = """
+context:
+  name: test-case-collision
+  version: 0.1.0
+
+package:
+  name: test-case-collision
+  version: 0.1.0
+
+build:
+  script:
+    # Create directories with case difference
+    - mkdir -p case_test
+    - echo "UPPER CASE FILE" > case_test/CASE-FILE.txt
+    - echo "lower case file" > case_test/case-file.txt
+    # Install the directory into the prefix to trigger packaging
+    - cp -r case_test $PREFIX/
+    # Add another test file to ensure packaging works
+    - echo "test content" > regular-file.txt
+    - cp regular-file.txt $PREFIX/
+
+about:
+  summary: A test package for case-insensitive file collisions
+"""
+    recipe_path = tmp_path / "recipe.yaml"
+    recipe_path.write_text(recipe_content)
+
+    args = rattler_build.build_args(
+        recipe_path,
+        tmp_path / "output",
+        extra_args=["-vvv"],
+    )
+
+    output = rattler_build(*args, stderr=STDOUT, text=True)
+    pkg = get_extracted_package(tmp_path / "output", "test-case-collision")
+    extracted_files_list = [str(f.relative_to(pkg)) for f in pkg.glob("**/*")]
+
+    assert (
+        "case_test/CASE-FILE.txt" in extracted_files_list
+    ), "CASE-FILE.txt not found in package"
+    assert (
+        "case_test/case-file.txt" in extracted_files_list
+    ), "case-file.txt not found in package"
+    assert (
+        "regular-file.txt" in extracted_files_list
+    ), "regular-file.txt not found in package"
+
+    collision_warning_pattern1 = "Mixed-case filenames detected, case-insensitive filesystems may break: case_test/CASE-FILE.txt, case_test/case-file.txt"
+    collision_warning_pattern2 = "Mixed-case filenames detected, case-insensitive filesystems may break: case_test/case-file.txt, case_test/CASE-FILE.txt"
+
+    assert (
+        collision_warning_pattern1 in output or collision_warning_pattern2 in output
+    ), f"Case collision warning not found in build output. Output contains:\n{output}"
+
+
 # This is how cf-scripts is using rattler-build - rendering recipes from stdin
 def test_rendering_from_stdin(
     rattler_build: RattlerBuild, recipes: Path, tmp_path: Path