Skip to content

Directory.EnumerateFiles does not respect per-directory case-sensitivity setting #39370

Closed
@ericwj

Description

@ericwj

See also #34235. Area System.IO.

Description

Summary

After creating file.txt and FILE.txt in a directory configured to be case-sensitive, running Directory.EnumerateFiles against it with a pattern of f* returns two elements instead of one, where FindFirstFileEx returns just the one.

Test result

  Message: 
    Assert.Equal() Failure
    Expected: String[] ["file.txt"]
    Actual:   String[] ["FILE.txt", "file.txt"]

Enable NtfsEnableDirCaseSensitivity globally (optional, recommended)

Have to elevate Visual Studio or review/enable/disable/reset opting into case-sensitivity (global setting) from elevated PowerShell before running the test:

Get-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem -Name NtfsEnableDirCaseSensitivity
/// # this will divide by zero => have to pick enabled (1) resp. disabled (0)
# Set-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem -Name NtfsEnableDirCaseSensitivity -Type DWord -Value (1/0)
# Remove-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem -Name NtfsEnableDirCaseSensitivity

Manually set the current directory to be case-sensitive

Not required, for demonstration purposes:

PS> fsutil file SetCaseSensitiveInfo $PWD enable
PS> fsutil file SetCaseSensitiveInfo $PWD disable

Useful links

Perhaps these:

XUnit test

Create project:

dotnet new xunit

Add references:

<ItemGroup>
  <PackageReference Include="Vanara.PInvoke.Kernel32" Version="3.2.12" />
  <PackageReference Include="Microsoft.Win32.Registry" Version="4.7.0" />
</ItemGroup>

Test:

private const string NtfsEnableDirCaseSensitivity = nameof(NtfsEnableDirCaseSensitivity);
/// <summary>Modifying the registry key will throw if not elevated. 
/// This doesn't change anything if it finds bit 1 set in
/// <c>SYSTEM\CurrentControlSet\Control\FileSystem DWORD NtfsEnableDirCaseSensitivity </c>.</summary>
private static bool TrySetCaseSensitivityGlobal(bool reset, ref int? oldValue) {
    const string fspath = @"SYSTEM\CurrentControlSet\Control\FileSystem";
    var fs = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(fspath);
    var o = fs.GetValue(NtfsEnableDirCaseSensitivity, null);
    var value = o is null ? (int?)null : (int)o;
    if (reset) {
        if (oldValue == null)
            fs.DeleteValue(NtfsEnableDirCaseSensitivity);
        else if (value != oldValue)
            fs.SetValue(NtfsEnableDirCaseSensitivity, oldValue.Value);
        return true;
    } else {
        oldValue = value;
        if (value.HasValue && (1 & value.Value) == 1) return true;
        if (oldValue == null)
            fs.SetValue(NtfsEnableDirCaseSensitivity, 1);
        else
            fs.SetValue(NtfsEnableDirCaseSensitivity, 1 | oldValue.Value);
        return true;
    }
}
private static bool TrySetDirectoryCaseSensitivity(bool enable, string path) {
    var status = enable ? "enable" : "disable";
    var fsutilpsi = new ProcessStartInfo("fsutil",
        $"file SetCaseSensitiveInfo \"{path}\" {status}") {
        CreateNoWindow = true,
        LoadUserProfile = false,
        RedirectStandardError = true,
        RedirectStandardOutput = true,
        UseShellExecute = false,
    };
    var fsutil = Process.Start(fsutilpsi);
    var @out = fsutil.StandardOutput.ReadToEnd();
    var err = fsutil.StandardError.ReadToEnd();
    fsutil.WaitForExit();
    if (!string.IsNullOrEmpty(@out)) Debug.WriteLine(@out);
    if (!string.IsNullOrEmpty(err)) Debug.WriteLine($"ERROR: {err}");
    return fsutil.ExitCode == 0 && string.IsNullOrEmpty(err);
}
[Fact]
public void EnumerateFilesCaseSensitiveDir() {
    var cwd = Environment.CurrentDirectory;
    var path = Environment.GetEnvironmentVariable("TEMP");
    path = Path.Combine(path, nameof(EnumerateFilesCaseSensitiveDir));
    path += $"-{new Random().Next():x8}";
    int? NtfsEnableDirCaseSensitivity = null;

    Directory.CreateDirectory(path);
    try {
        Environment.CurrentDirectory = path;
        try {
            Assert.True(TrySetCaseSensitivityGlobal(reset: false, ref NtfsEnableDirCaseSensitivity));
            try {
                Assert.True(TrySetDirectoryCaseSensitivity(enable: true, path));
                File.WriteAllText("file.txt", "lowercase");
                File.WriteAllText("FILE.txt", "uppercase");
                string[] expected = null;
                using (var searchHandle = Kernel32.FindFirstFileEx(@$"{path}\f*",
                    Kernel32.FINDEX_INFO_LEVELS.FindExInfoBasic,
                    out var ffdata,
                    Kernel32.FINDEX_SEARCH_OPS.FindExSearchNameMatch,
                    lpSearchFilter: default,
                    Kernel32.FIND_FIRST.FIND_FIRST_EX_CASE_SENSITIVE)) {

                    var results = new List<string>();
                    while (!searchHandle.IsInvalid) {
                        results.Add(Path.GetRelativePath(path, ffdata.cFileName));
                        if (!Kernel32.FindNextFile(searchHandle, out ffdata)) break;
                    }
                    expected = results.ToArray();
                } // FindClose; searchHandle.handle = default;

                var actual = Directory
                    .EnumerateFiles(path, "f*.*", SearchOption.TopDirectoryOnly)
                    .Select(file => Path.GetRelativePath(path, file))
                    .ToArray();
                Assert.Equal(expected, actual);
            } finally {
                Assert.True(TrySetCaseSensitivityGlobal(reset: true, ref NtfsEnableDirCaseSensitivity));
            }
        } finally {
            Environment.CurrentDirectory = cwd;
        }
    } finally {
        Directory.Delete(path, recursive: true);
    }
}

Configuration

PS> dotnet --list-sdks
3.1.301 [C:\Program Files\dotnet\sdk]
3.1.400-preview-015178 [C:\Program Files\dotnet\sdk]
5.0.100-preview.5.20279.10 [C:\Program Files\dotnet\sdk]

Regression?

No. Haven't checked, but no.

Opinion

There is an overload of Directory.EnumerateFiles which takes EnumerationOptions that can have its MatchCasing property set to MatchCasing.CaseSensitive, but it might be more convenient, useful and correct to auto-detect case-sensitivity on behalf of the caller on a directory-by-directory basis through the use of perhaps NtQueryInformationFile once per directory during enumeration. Such behavior can today not be specified in any way.

It is slightly disappointing to see the complexity of the imlementation of EnumerateFiles and similar, doing pattern patching and calling into NtXxx instead of more regular Win32 API surface, since that complicates implementing any feature like this.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions