Skip to content

fix: respect case sensitivity of filesystem when collecting new files #1699

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 168 additions & 10 deletions src/packaging/file_finder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,38 @@ use walkdir::WalkDir;

use crate::{metadata::Output, recipe::parser::GlobVec};

use super::{PackagingError, file_mapper};
use super::{PackagingError, file_mapper, normalize_path_for_comparison};

/// A wrapper around PathBuf that implements case-insensitive hashing and equality
/// when the filesystem is case-insensitive
#[derive(Debug, Clone)]
struct CaseInsensitivePath {
path: String,
}

impl CaseInsensitivePath {
fn new(path: &Path) -> Self {
Self {
path: normalize_path_for_comparison(path, true).unwrap(),
}
}
}

impl std::hash::Hash for CaseInsensitivePath {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
// Convert to lowercase string for case-insensitive hashing
self.path.hash(state);
}
}

impl PartialEq for CaseInsensitivePath {
fn eq(&self, other: &Self) -> bool {
// Case-insensitive comparison
self.path == other.path
}
}

impl Eq for CaseInsensitivePath {}

/// This struct keeps a record of all the files that are new in the prefix (i.e. not present in the previous
/// conda environment).
Expand Down Expand Up @@ -64,6 +95,52 @@ pub fn record_files(directory: &Path) -> Result<HashSet<PathBuf>, io::Error> {
Ok(res)
}

// Check if the filesystem is case-sensitive by creating a file with a different case
// and checking if it exists.
fn check_is_case_sensitive() -> Result<bool, io::Error> {
// Check if the filesystem is case insensitive
let tempdir = TempDir::new()?;
let file1 = tempdir.path().join("testfile.txt");
let file2 = tempdir.path().join("TESTFILE.txt");
fs::File::create(&file1)?;
Ok(!file2.exists() && file1.exists())
}

/// Helper function to find files that exist in current_files but not in previous_files,
/// taking into account case sensitivity
fn find_new_files(
current_files: &HashSet<PathBuf>,
previous_files: &HashSet<PathBuf>,
prefix: &Path,
is_case_sensitive: bool,
) -> HashSet<PathBuf> {
if is_case_sensitive {
// On case-sensitive filesystems, use normal set difference
current_files.difference(previous_files).cloned().collect()
} else {
// On case-insensitive filesystems, use case-aware comparison
let previous_case_aware: HashSet<CaseInsensitivePath> = previous_files
.iter()
.map(|p| {
CaseInsensitivePath::new(p.strip_prefix(prefix).expect("File should be in prefix"))
})
.collect();

let current_files = current_files
.clone()
.into_iter()
.filter(|p| {
// Only include files that are not in the previous set
!previous_case_aware.contains(&CaseInsensitivePath::new(
p.strip_prefix(prefix).expect("File should be in prefix"),
))
})
.collect::<HashSet<_>>();

current_files
}
}

impl Files {
/// Find all files in the given (host) prefix and remove all previously installed files (based on the PrefixRecord
/// of the conda environment). If always_include is Some, then all files matching the glob pattern will be included
Expand All @@ -81,6 +158,8 @@ impl Files {
});
}

let fs_is_case_sensitive = check_is_case_sensitive()?;

let previous_files = if prefix.join("conda-meta").exists() {
let prefix_records: Vec<PrefixRecord> = PrefixRecord::collect_from_prefix(prefix)?;
let mut previous_files =
Expand All @@ -99,16 +178,23 @@ impl Files {
};

let current_files = record_files(prefix)?;
let mut difference = current_files
.difference(&previous_files)
// If we have an files glob, we only include files that match the glob
.filter(|f| {
files.is_empty()
|| files.is_match(f.strip_prefix(prefix).expect("File should be in prefix"))
})
.cloned()
.collect::<HashSet<_>>();

// Use case-aware difference calculation
let mut difference = find_new_files(
&current_files,
&previous_files,
prefix,
fs_is_case_sensitive,
);

// Filter by files glob if specified
if !files.is_empty() {
difference.retain(|f| {
files.is_match(f.strip_prefix(prefix).expect("File should be in prefix"))
});
}

// Handle always_include files
if !always_include.is_empty() {
for file in current_files {
let file_without_prefix =
Expand Down Expand Up @@ -171,3 +257,75 @@ impl TempFiles {
&self.content_type_map
}
}

#[cfg(test)]
mod test {
use std::{collections::HashSet, path::PathBuf};

use crate::packaging::file_finder::{check_is_case_sensitive, find_new_files};

#[test]
fn test_find_new_files_case_sensitive() {
let current_files: HashSet<PathBuf> = [
PathBuf::from("/test/File.txt"),
PathBuf::from("/test/file.txt"),
PathBuf::from("/test/common.txt"),
]
.into_iter()
.collect();

let previous_files: HashSet<PathBuf> = [
PathBuf::from("/test/File.txt"),
PathBuf::from("/test/common.txt"),
]
.into_iter()
.collect();

let prefix = PathBuf::from("/test");
let new_files = find_new_files(&current_files, &previous_files, &prefix, true);

// On case-sensitive filesystem, file.txt should be considered new
assert_eq!(new_files.len(), 1);
assert!(new_files.contains(&PathBuf::from("/test/file.txt")));
}

#[test]
fn test_find_new_files_case_insensitive() {
let current_files: HashSet<PathBuf> = [
PathBuf::from("/test/File.txt"),
PathBuf::from("/test/file.txt"),
PathBuf::from("/test/common.txt"),
PathBuf::from("/test/NEW.txt"),
]
.into_iter()
.collect();

let previous_files: HashSet<PathBuf> = [
PathBuf::from("/test/FILE.TXT"), // Different case of File.txt
PathBuf::from("/test/common.txt"),
]
.into_iter()
.collect();

let prefix = PathBuf::from("/test");
let new_files = find_new_files(&current_files, &previous_files, &prefix, false);

// On case-insensitive filesystem, only NEW.txt should be considered new
// Both File.txt and file.txt should be considered as existing (matching FILE.TXT)
assert_eq!(new_files.len(), 1);
assert!(new_files.contains(&PathBuf::from("/test/NEW.txt")));
assert!(!new_files.contains(&PathBuf::from("/test/File.txt")));
assert!(!new_files.contains(&PathBuf::from("/test/file.txt")));
}

#[test]
fn test_check_is_case_sensitive() {
// This test will behave differently on different filesystems
let result = check_is_case_sensitive();
assert!(result.is_ok());

// We can't assert the specific value since it depends on the filesystem,
// but we can verify the function doesn't panic and returns a boolean
let _is_case_sensitive = result.unwrap();
}
}
2 changes: 1 addition & 1 deletion src/packaging/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pub fn contains_prefix_binary(file_path: &Path, prefix: &Path) -> Result<bool, P
// TODO on Windows check both ascii and utf-8 / 16?
#[cfg(target_family = "windows")]
{
tracing::warn!("Windows is not supported yet for binary prefix checking.");
tracing::debug!("Windows is not supported yet for binary prefix checking.");
Ok(false)
}

Expand Down
40 changes: 40 additions & 0 deletions test-data/recipes/case-insensitive/recipe.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
recipe:
name: case-insensitive-test
version: "1.0.0"

outputs:
- package:
name: c1

build:
script:
- if: win
then:
- mkdir %PREFIX%\CMake\
- echo "This is a test file for case insensitivity" > %PREFIX%\CMake\test_file.txt
else:
- mkdir -p $PREFIX/CMake/
- echo "This is a test file for case insensitivity" > $PREFIX/CMake/test_file.txt

- package:
name: c2

requirements:
host:
- c1

build:
script:
- if: win
then:
- rmdir /S /Q %PREFIX%\CMake\
- mkdir %PREFIX%\cmake\
- echo "foo" > %PREFIX%\TEST.txt
- echo "bar" > %PREFIX%\test.txt
- echo "This is a test file for case insensitivity" > %PREFIX%\cmake\test_file.txt
else:
- rm -rf $PREFIX/CMake/
- mkdir -p $PREFIX/cmake/
- echo "foo" > $PREFIX/TEST.txt
- echo "bar" > $PREFIX/test.txt
- echo "This is a test file for case insensitivity" > $PREFIX/cmake/test_file.txt
33 changes: 33 additions & 0 deletions test/end-to-end/test_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -2044,3 +2044,36 @@ def test_condapackageignore(rattler_build: RattlerBuild, recipes: Path, tmp_path
assert (files_dir / "recipe.yaml").exists()
assert not (files_dir / "ignored.txt").exists()
assert not (files_dir / "test.pyc").exists()


def test_caseinsensitive(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
"""Test that case-insensitive file systems handle files correctly."""
# Build the package with a recipe that has mixed-case filenames
rattler_build.build(
recipes / "case-insensitive/recipe.yaml",
tmp_path,
)

pkg = get_extracted_package(tmp_path, "c2")

# check if the current filesystem is case-insensitive by creating a temporary file with a mixed case name
test_file = tmp_path / "MixedCaseFile.txt"
mixed_case_file = tmp_path / "mixedcasefile.txt"

# create the mixed-case files
test_file.write_text("This is a test.")
case_insensitive = mixed_case_file.exists()

paths_json = (pkg / "info/paths.json").read_text()
paths = json.loads(paths_json)
paths = [p["_path"] for p in paths["paths"]]

if case_insensitive:
# we don't package `cmake/test_file.txt` again, because our dependency already contains `CMake/test_file.txt`
assert len(paths) == 1
assert "TEST.txt" in paths or "test.txt" in paths
else:
assert len(paths) == 3
assert "cmake/test_file.txt" in paths
assert "TEST.txt" in paths
assert "test.txt" in paths
Loading