]> git.ipfire.org Git - thirdparty/ccache.git/commitdiff
fix: Use Unicode-aware case comparison for Windows paths
authorJoel Rosdahl <joel@rosdahl.net>
Fri, 7 Aug 2026 08:44:53 +0000 (10:44 +0200)
committerJoel Rosdahl <joel@rosdahl.net>
Fri, 7 Aug 2026 08:54:17 +0000 (10:54 +0200)
src/ccache/ccache.cpp
src/ccache/context.cpp
src/ccache/util/path.cpp
src/ccache/util/path.hpp
unittest/test_ccache.cpp
unittest/test_util_path.cpp

index 3790826789b88c26a09f1e83923d4ad418e1fda9..8db1441c37d2765e957979b32bdd59838b44b263 100644 (file)
@@ -429,12 +429,8 @@ remember_include_file(Context& ctx,
 
   if (!ctx.ignore_header_paths.empty()) {
     // Canonicalize path for comparison; Clang uses ./header.h.
-    auto canonical_path_str =
+    const std::string& canonical_path_str =
       path_str.str().starts_with("./") ? path_str.str().substr(2) : path_str;
-#ifdef _WIN32
-    // Handle case-insensitive paths by converting to lowercase.
-    canonical_path_str = util::to_lowercase(canonical_path_str);
-#endif
     for (const auto& ignore_header_path : ctx.ignore_header_paths) {
       if (file_path_matches_dir_prefix_or_file(ignore_header_path,
                                                canonical_path_str)) {
@@ -3200,11 +3196,7 @@ do_cache_compilation(Context& ctx)
 bool
 is_ccache_executable(const fs::path& path)
 {
-  std::string name = path.filename().string();
-#ifdef _WIN32
-  name = util::to_lowercase(name);
-#endif
-  return name.starts_with("ccache");
+  return util::path_component_starts_with_case_aware(path.filename(), "ccache");
 }
 
 bool
@@ -3217,7 +3209,8 @@ file_path_matches_dir_prefix_or_file(const fs::path& dir_prefix_or_file,
   auto end = std::mismatch(dir_prefix_or_file.begin(),
                            dir_prefix_or_file.end(),
                            file_path.begin(),
-                           file_path.end())
+                           file_path.end(),
+                           util::path_components_equal_case_aware)
                .first;
   return end == dir_prefix_or_file.end() || end->empty();
 }
index 99ff4ec4e5202207c95da69a424eefee4efd809c..adcf9f4565ef18a49c4b269aaa020498412dc2e7 100644 (file)
@@ -59,12 +59,6 @@ Context::initialize(util::Args&& compiler_and_args,
   util::logging::init(config.debug(), config.log_file());
   ignore_header_paths =
     util::split_path_list(config.ignore_headers_in_manifest());
-#ifdef _WIN32
-  // Handle case-insensitive paths by converting to lowercase.
-  for (auto& path : ignore_header_paths) {
-    path = util::to_lowercase(path.string());
-  }
-#endif
   set_ignore_options(util::split_into_strings(config.ignore_options(), " "));
 
   // Set default umask for all files created by ccache from now on (if
index 64151c26ad575dddd1868a79e55441b8ba316b31..f7feae555dea28abe7f182dc40795b0614d8d820 100644 (file)
 
 #include "path.hpp"
 
+#include <ccache/util/assertions.hpp>
 #include <ccache/util/direntry.hpp>
 #include <ccache/util/filesystem.hpp>
 #include <ccache/util/format.hpp>
-#include <ccache/util/string.hpp>
+#include <ccache/util/wincompat.hpp>
 
 #include <algorithm>
+#include <utility>
 
 #ifdef _WIN32
 const char k_dev_null_path[] = "nul:";
@@ -39,12 +41,46 @@ fs::path
 lexically_relative_case_aware(const fs::path& path, const fs::path& base)
 {
 #ifdef _WIN32
-  // Note: Case-folding might in theory lead to an incorrect path on Windows
-  // since not all filesystems are case-insensitive, but this is only done to
-  // produce a candidate path that will be verified by the caller later.
-  fs::path p = util::to_lowercase(path.string());
-  fs::path b = util::to_lowercase(base.string());
-  return p.lexically_relative(b);
+  // Note: Case-insensitive comparison might in theory lead to an incorrect path
+  // on Windows since not all filesystems are case-insensitive, but this is only
+  // done to produce a candidate path that will be verified by the caller later.
+  if (!util::path_components_equal_case_aware(path.root_name(),
+                                              base.root_name())
+      || path.is_absolute() != base.is_absolute()
+      || (!path.has_root_directory() && base.has_root_directory())) {
+    return {};
+  }
+
+  auto [path_it, base_it] =
+    std::mismatch(path.begin(),
+                  path.end(),
+                  base.begin(),
+                  base.end(),
+                  util::path_components_equal_case_aware);
+  if (path_it == path.end() && base_it == base.end()) {
+    return ".";
+  }
+
+  int num_parents = 0;
+  for (; base_it != base.end(); ++base_it) {
+    if (*base_it == "..") {
+      --num_parents;
+    } else if (*base_it != "." && !base_it->empty()) {
+      ++num_parents;
+    }
+  }
+  if (num_parents < 0) {
+    return {};
+  }
+
+  fs::path result;
+  for (int i = 0; i < num_parents; ++i) {
+    result /= "..";
+  }
+  for (; path_it != path.end(); ++path_it) {
+    result /= *path_it;
+  }
+  return result;
 #else
   return path.lexically_relative(base);
 #endif
@@ -141,6 +177,58 @@ make_relative_path(const fs::path& dir1,
   return path;
 }
 
+bool
+path_components_equal_case_aware(const fs::path& component1,
+                                 const fs::path& component2)
+{
+#ifdef _WIN32
+  const auto& string1 = component1.native();
+  const auto& string2 = component2.native();
+  if (string1.empty() || string2.empty()) {
+    return string1.empty() && string2.empty();
+  }
+  if (!std::in_range<int>(string1.size())
+      || !std::in_range<int>(string2.size())) {
+    return false;
+  }
+  return CompareStringOrdinal(string1.data(),
+                              static_cast<int>(string1.size()),
+                              string2.data(),
+                              static_cast<int>(string2.size()),
+                              TRUE)
+         == CSTR_EQUAL;
+#else
+  return component1.native() == component2.native();
+#endif
+}
+
+bool
+path_component_starts_with_case_aware(const fs::path& component,
+                                      const fs::path& prefix)
+{
+  const auto& string = component.native();
+  const auto& prefix_string = prefix.native();
+  if (prefix_string.size() > string.size()) {
+    return false;
+  }
+#ifdef _WIN32
+  if (prefix_string.empty()) {
+    return true;
+  }
+  if (!std::in_range<int>(prefix_string.size())) {
+    return false;
+  }
+  return CompareStringOrdinal(string.data(),
+                              static_cast<int>(prefix_string.size()),
+                              prefix_string.data(),
+                              static_cast<int>(prefix_string.size()),
+                              TRUE)
+         == CSTR_EQUAL;
+#else
+  return string.starts_with(prefix_string);
+#endif
+}
+
 bool
 path_starts_with(const fs::path& path, const fs::path& prefix)
 {
@@ -148,8 +236,8 @@ path_starts_with(const fs::path& path, const fs::path& prefix)
   // Note: Not all paths on Windows are case insensitive, but for our purposes
   // (checking whether a path is below the base directory) users will expect
   // them to be.
-  fs::path p1 = util::to_lowercase(util::lexically_normal(path).string());
-  fs::path p2 = util::to_lowercase(util::lexically_normal(prefix).string());
+  fs::path p1 = util::lexically_normal(path);
+  fs::path p2 = util::lexically_normal(prefix);
 #else
   const fs::path& p1 = path;
   const fs::path& p2 = prefix;
@@ -164,7 +252,12 @@ path_starts_with(const fs::path& path, const fs::path& prefix)
     }
   }
 
-  return std::mismatch(p1.begin(), p1.end(), p2.begin(), p2_end).second
+  return std::mismatch(p1.begin(),
+                       p1.end(),
+                       p2.begin(),
+                       p2_end,
+                       util::path_components_equal_case_aware)
+           .second
          == p2_end;
 }
 
index 3373aab11c06d0f36755c8773784f0b648fa81e4..e25795e1dbd3c60ee95858510e4fa53bdc5d85c1 100644 (file)
@@ -19,9 +19,6 @@
 #pragma once
 
 #include <ccache/util/pathstring.hpp>
-#ifdef _WIN32
-#  include <ccache/util/string.hpp>
-#endif
 
 #include <filesystem>
 #include <string>
@@ -74,6 +71,17 @@ make_path(const T&... args)
   return (std::filesystem::path{} / ... / args).lexically_normal();
 }
 
+// Return whether `component1` and `component2` are equal. Comparison is case
+// insensitive on Windows and case sensitive on other platforms.
+bool path_components_equal_case_aware(const std::filesystem::path& component1,
+                                      const std::filesystem::path& component2);
+
+// Return whether `component` starts with `prefix`. Comparison is case
+// insensitive on Windows and case sensitive on other platforms.
+bool
+path_component_starts_with_case_aware(const std::filesystem::path& component,
+                                      const std::filesystem::path& prefix);
+
 // Return whether `path` starts with `prefix` considering path specifics on
 // Windows.
 bool path_starts_with(const std::filesystem::path& path,
@@ -108,7 +116,7 @@ is_dev_null_path(const std::filesystem::path& path)
 {
   return path == "/dev/null"
 #ifdef _WIN32
-         || util::to_lowercase(path.string()) == "nul"
+         || path_components_equal_case_aware(path, "nul")
 #endif
     ;
 }
index 20c2e644b25217fb5f05c998045a7b033c0eeea6..854202b196673f4a65e32228b5a38100e097a9e1 100644 (file)
@@ -297,6 +297,9 @@ TEST_CASE("file_path_matches_dir_prefix_or_file")
 #ifdef _WIN32
   CHECK(file_path_matches_dir_prefix_or_file("\\aa", "\\aa\\bb"));
   CHECK(file_path_matches_dir_prefix_or_file("\\aa\\", "\\aa\\bb"));
+  CHECK(file_path_matches_dir_prefix_or_file(
+    fs::path(L"C:\\\u00c5ngstr\u00f6m"),
+    fs::path(L"c:\\\u00e5NGSTR\u00d6M\\header.h")));
 #else
   CHECK(!file_path_matches_dir_prefix_or_file("\\aa", "\\aa\\bb"));
   CHECK(!file_path_matches_dir_prefix_or_file("\\aa\\", "\\aa\\bb"));
index a84cfcdd1af0ca05ea4c67aeb237437a1b2e5b54..1e02eb0d36e108def370ca1cd1b256b4ee57b6ce 100644 (file)
@@ -70,6 +70,35 @@ TEST_CASE("util::is_dev_null_path")
 #endif
 }
 
+TEST_CASE("util::path_components_equal_case_aware")
+{
+  CHECK(util::path_components_equal_case_aware("", ""));
+  CHECK(util::path_components_equal_case_aware("foo", "foo"));
+  CHECK(!util::path_components_equal_case_aware("foo", "bar"));
+#ifdef _WIN32
+  CHECK(util::path_components_equal_case_aware("FOO", "foo"));
+  CHECK(util::path_components_equal_case_aware(fs::path(L"\u00c5"),
+                                               fs::path(L"\u00e5")));
+#else
+  CHECK(!util::path_components_equal_case_aware("FOO", "foo"));
+#endif
+}
+
+TEST_CASE("util::path_component_starts_with_case_aware")
+{
+  CHECK(util::path_component_starts_with_case_aware("foo", ""));
+  CHECK(util::path_component_starts_with_case_aware("foobar", "foo"));
+  CHECK(!util::path_component_starts_with_case_aware("foo", "foobar"));
+  CHECK(!util::path_component_starts_with_case_aware("foobar", "bar"));
+#ifdef _WIN32
+  CHECK(util::path_component_starts_with_case_aware("FOOBAR", "foo"));
+  CHECK(util::path_component_starts_with_case_aware(
+    fs::path(L"\u00c5ngstr\u00f6m"), fs::path(L"\u00e5NG")));
+#else
+  CHECK(!util::path_component_starts_with_case_aware("FOOBAR", "foo"));
+#endif
+}
+
 TEST_CASE("util::lexically_normal")
 {
   CHECK(util::lexically_normal("") == "");
@@ -166,6 +195,15 @@ TEST_CASE("util::make_relative_path")
     CHECK(make_relative_path(
             lower_drive_cwd, lower_drive_cwd, upper_drive_cwd + "/nonexistent")
           == "nonexistent");
+
+    const fs::path unicode_dir = fs::path(L"\u00c5ngstr\u00f6m");
+    const fs::path unicode_dir_case_variant = fs::path(L"\u00e5NGSTR\u00d6M");
+    REQUIRE(fs::create_directory(unicode_dir));
+    CHECK(make_relative_path(fs::path(actual_cwd) / unicode_dir,
+                             fs::path(actual_cwd) / unicode_dir,
+                             fs::path(actual_cwd) / unicode_dir_case_variant
+                               / "nonexistent")
+          == "nonexistent");
   }
 #endif
 
@@ -199,6 +237,8 @@ TEST_CASE("util::path_starts_with")
   CHECK(util::path_starts_with("c:/foo/bar", "C:\\FOO"));
   CHECK(util::path_starts_with("c:/foo/bar/", "C:\\FOO"));
   CHECK(util::path_starts_with("c:/foo/bar", "C:\\FOO\\"));
+  CHECK(util::path_starts_with(fs::path(L"C:\\\u00c5ngstr\u00f6m\\bar"),
+                               fs::path(L"c:/\u00e5NGSTR\u00d6M")));
   CHECK(!util::path_starts_with("C:\\foo\\bar", "/foo/baz"));
   CHECK(!util::path_starts_with("C:\\foo\\bar", "C:/foo/baz"));
   CHECK(!util::path_starts_with("C:\\beh\\foo", "/foo"));